forked from fa/breadcrumb-the-shire
Rename the top-level lib/ directory to core/ so the project root immediately communicates which code is core platform and which lives in modules/. PHP namespaces (MintyPHP\*) are unchanged — only the Composer PSR-4 path mapping moves from lib/ to core/. Module-internal lib/ directories (modules/*/lib/) are untouched. Updated across the full stack: - composer.json PSR-4 mapping - bootstrap entry points (web/index.php, bin/*, tests/bootstrap.php) - tooling configs (phpstan.neon, phpunit.xml, php-cs-fixer) - 26 architecture contract tests - enforcement-policy, guard-catalog, quality-gates - all documentation (CLAUDE.md, README, 25 docs/, .agents/skills/) - bin/qa-extended.sh search paths - .claude/settings.local.json permission paths Workflow: .agents/runs/CORE-LIB-RENAME-001/ (Analyst → Planner → Executor → Code Review (4 findings fixed) → Security Review → Acceptance Test → Finalizer) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1087 lines
55 KiB
PHP
1087 lines
55 KiB
PHP
<?php
|
|
|
|
namespace MintyPHP\Repository\Stats;
|
|
|
|
use MintyPHP\DB;
|
|
use MintyPHP\Domain\Taxonomy\MailLogStatus;
|
|
use MintyPHP\Domain\Taxonomy\ScheduledJobRunStatus;
|
|
use MintyPHP\Domain\Taxonomy\ScheduledJobTriggerType;
|
|
use MintyPHP\Domain\Taxonomy\TenantStatus;
|
|
|
|
class AdminStatsRepository implements AdminStatsRepositoryInterface
|
|
{
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function buildViewData(): array
|
|
{
|
|
$tenantActiveStatus = TenantStatus::Active->value;
|
|
$tenantInactiveStatus = TenantStatus::Inactive->value;
|
|
$importStatusRunning = 'running';
|
|
$importStatusSuccess = 'success';
|
|
$importStatusPartial = 'partial';
|
|
$importStatusFailed = 'failed';
|
|
$systemAuditOutcomeFailed = 'failed';
|
|
$systemAuditOutcomeDenied = 'denied';
|
|
$lifecycleStatusFailed = 'failed';
|
|
$lifecycleStatusSkipped = 'skipped';
|
|
$lifecycleActionRestore = 'restore';
|
|
$schedulerTriggerScheduler = ScheduledJobTriggerType::Scheduler->value;
|
|
$schedulerRunStatusFailed = ScheduledJobRunStatus::Failed->value;
|
|
$mailStatusFailed = MailLogStatus::Failed->value;
|
|
$mailStatusSent = MailLogStatus::Sent->value;
|
|
|
|
$activeUserCount = (int) (DB::selectValue('select count(*) from users where active = 1') ?? 0);
|
|
$inactiveUserCount = (int) (DB::selectValue('select count(*) from users where active = 0') ?? 0);
|
|
$activeTenantCount = (int) (DB::selectValue('select count(*) from tenants where status = ?', $tenantActiveStatus) ?? 0);
|
|
$inactiveTenantCount = (int) (DB::selectValue('select count(*) from tenants where status = ?', $tenantInactiveStatus) ?? 0);
|
|
$activeDepartmentCount = (int) (DB::selectValue('select count(*) from departments where active = 1') ?? 0);
|
|
$inactiveDepartmentCount = (int) (DB::selectValue('select count(*) from departments where active = 0') ?? 0);
|
|
$activeRoleCount = (int) (DB::selectValue('select count(*) from roles where active = 1') ?? 0);
|
|
$inactiveRoleCount = (int) (DB::selectValue('select count(*) from roles where active = 0') ?? 0);
|
|
|
|
$rolesWithoutPermissionsCount = (int) (DB::selectValue(
|
|
'select count(*) from roles r left join role_permissions rp on rp.role_id = r.id where r.active = 1 and rp.role_id is null'
|
|
) ?? 0);
|
|
$permissionsWithoutRolesCount = (int) (DB::selectValue(
|
|
'select count(*) from permissions p left join role_permissions rp on rp.permission_id = p.id where p.active = 1 and rp.permission_id is null'
|
|
) ?? 0);
|
|
$usersWithoutRolesCount = (int) (DB::selectValue(
|
|
'select count(*) from users u left join user_roles ur on ur.user_id = u.id where u.active = 1 and ur.user_id is null'
|
|
) ?? 0);
|
|
$usersWithoutTenantsCount = (int) (DB::selectValue(
|
|
'select count(*) from users u left join user_tenants ut on ut.user_id = u.id where u.active = 1 and ut.user_id is null'
|
|
) ?? 0);
|
|
|
|
$flattenStatsRow = static function ($row): array {
|
|
if (!is_array($row)) {
|
|
return [];
|
|
}
|
|
if (isset($row['']) && is_array($row[''])) {
|
|
return $row[''];
|
|
}
|
|
$flat = [];
|
|
foreach ($row as $key => $value) {
|
|
if (is_array($value)) {
|
|
$flat = array_merge($flat, $value);
|
|
} elseif (is_string($key)) {
|
|
$flat[$key] = $value;
|
|
}
|
|
}
|
|
return $flat;
|
|
};
|
|
|
|
$ssoStatsAvailable = (int) (DB::selectValue(
|
|
"select count(*) from information_schema.tables where table_schema = database() and table_name = 'tenant_auth_microsoft'"
|
|
) ?? 0) === 1;
|
|
$ssoEnabledTenantCount = 0;
|
|
$ssoEnforcedTenantCount = 0;
|
|
$ssoEnabledTenantPercent = 0.0;
|
|
$usersMicrosoftOnlyCount = 0;
|
|
$usersMicrosoftSyncGapsCount = 0;
|
|
$ssoTenantRows = [];
|
|
$usersMicrosoftOnlyRows = [];
|
|
$usersMicrosoftSyncGapRows = [];
|
|
|
|
if ($ssoStatsAvailable) {
|
|
$ssoEnabledTenantCount = (int) (DB::selectValue(
|
|
"select count(*) from tenants t " .
|
|
"left join tenant_auth_microsoft tam on tam.tenant_id = t.id " .
|
|
"where t.status = ? and coalesce(tam.enabled, 0) = 1",
|
|
$tenantActiveStatus
|
|
) ?? 0);
|
|
$ssoEnforcedTenantCount = (int) (DB::selectValue(
|
|
"select count(*) from tenants t " .
|
|
"left join tenant_auth_microsoft tam on tam.tenant_id = t.id " .
|
|
"where t.status = ? and coalesce(tam.enabled, 0) = 1 and coalesce(tam.enforce_microsoft_login, 0) = 1",
|
|
$tenantActiveStatus
|
|
) ?? 0);
|
|
if ($activeTenantCount > 0) {
|
|
$ssoEnabledTenantPercent = round(((float) $ssoEnabledTenantCount * 100) / (float) $activeTenantCount, 1);
|
|
}
|
|
|
|
$usersMicrosoftOnlyCount = (int) (DB::selectValue(
|
|
"select count(*) from users u " .
|
|
"where u.active = 1 " .
|
|
"and exists ( " .
|
|
" select 1 from user_tenants ut " .
|
|
" join tenants t on t.id = ut.tenant_id and t.status = ? " .
|
|
" where ut.user_id = u.id " .
|
|
") " .
|
|
"and not exists ( " .
|
|
" select 1 from user_tenants ut " .
|
|
" join tenants t on t.id = ut.tenant_id and t.status = ? " .
|
|
" left join tenant_auth_microsoft tam on tam.tenant_id = t.id " .
|
|
" where ut.user_id = u.id " .
|
|
" and (coalesce(tam.enabled, 0) = 0 or coalesce(tam.enforce_microsoft_login, 0) = 0) " .
|
|
")",
|
|
$tenantActiveStatus,
|
|
$tenantActiveStatus
|
|
) ?? 0);
|
|
|
|
$usersMicrosoftSyncGapsCount = (int) (DB::selectValue(
|
|
"select count(*) from ( " .
|
|
" select u.id, " .
|
|
" trim(coalesce(u.first_name, '')) as first_name_value, " .
|
|
" trim(coalesce(u.last_name, '')) as last_name_value, " .
|
|
" trim(coalesce(u.phone, '')) as phone_value, " .
|
|
" trim(coalesce(u.mobile, '')) as mobile_value, " .
|
|
" max(find_in_set('first_name', coalesce(nullif(tam.sync_profile_fields, ''), 'first_name,last_name')) > 0) as need_first_name, " .
|
|
" max(find_in_set('last_name', coalesce(nullif(tam.sync_profile_fields, ''), 'first_name,last_name')) > 0) as need_last_name, " .
|
|
" max(find_in_set('phone', coalesce(nullif(tam.sync_profile_fields, ''), 'first_name,last_name')) > 0) as need_phone, " .
|
|
" max(find_in_set('mobile', coalesce(nullif(tam.sync_profile_fields, ''), 'first_name,last_name')) > 0) as need_mobile " .
|
|
" from users u " .
|
|
" join user_tenants ut on ut.user_id = u.id " .
|
|
" join tenants t on t.id = ut.tenant_id and t.status = ? " .
|
|
" join tenant_auth_microsoft tam on tam.tenant_id = t.id " .
|
|
" where u.active = 1 and u.last_login_provider = 'microsoft' and tam.enabled = 1 and tam.sync_profile_on_login = 1 " .
|
|
" group by u.id, u.first_name, u.last_name, u.phone, u.mobile " .
|
|
") x " .
|
|
"where (x.need_first_name = 1 and x.first_name_value = '') " .
|
|
" or (x.need_last_name = 1 and x.last_name_value = '') " .
|
|
" or (x.need_phone = 1 and x.phone_value = '') " .
|
|
" or (x.need_mobile = 1 and x.mobile_value = '')",
|
|
$tenantActiveStatus
|
|
) ?? 0);
|
|
|
|
$ssoTenantRowsRaw = DB::select(
|
|
"select " .
|
|
" t.uuid as tenant_uuid, " .
|
|
" t.description as tenant_description, " .
|
|
" coalesce(tam.enabled, 0) as sso_enabled, " .
|
|
" coalesce(tam.enforce_microsoft_login, 0) as enforce_microsoft_login, " .
|
|
" coalesce(tam.sync_profile_on_login, 0) as sync_profile_on_login, " .
|
|
" coalesce(nullif(tam.sync_profile_fields, ''), '-') as sync_profile_fields " .
|
|
"from tenants t " .
|
|
"left join tenant_auth_microsoft tam on tam.tenant_id = t.id " .
|
|
"where t.status = ? " .
|
|
"order by sso_enabled desc, enforce_microsoft_login desc, t.description asc " .
|
|
"limit 10",
|
|
$tenantActiveStatus
|
|
);
|
|
$ssoTenantRows = is_array($ssoTenantRowsRaw) ? array_values(array_filter(array_map(
|
|
static function ($row) use ($flattenStatsRow): ?array {
|
|
$flat = $flattenStatsRow($row);
|
|
$tenantUuid = trim((string) ($flat['tenant_uuid'] ?? ($flat['uuid'] ?? '')));
|
|
$tenantDescription = trim((string) ($flat['tenant_description'] ?? ($flat['description'] ?? '')));
|
|
if ($tenantUuid === '' || $tenantDescription === '') {
|
|
return null;
|
|
}
|
|
return [
|
|
'tenant_uuid' => $tenantUuid,
|
|
'tenant_description' => $tenantDescription,
|
|
'sso_enabled' => (int) ($flat['sso_enabled'] ?? ($flat['enabled'] ?? 0)),
|
|
'enforce_microsoft_login' => (int) ($flat['enforce_microsoft_login'] ?? 0),
|
|
'sync_profile_on_login' => (int) ($flat['sync_profile_on_login'] ?? 0),
|
|
'sync_profile_fields' => trim((string) ($flat['sync_profile_fields'] ?? '-')),
|
|
];
|
|
},
|
|
$ssoTenantRowsRaw
|
|
))) : [];
|
|
|
|
$usersMicrosoftOnlyRowsRaw = DB::select(
|
|
"select " .
|
|
" u.uuid as user_uuid, " .
|
|
" u.display_name as display_name, " .
|
|
" u.email as email, " .
|
|
" group_concat(distinct t.description order by t.description separator ', ') as tenant_labels " .
|
|
"from users u " .
|
|
"join user_tenants ut on ut.user_id = u.id " .
|
|
"join tenants t on t.id = ut.tenant_id and t.status = ? " .
|
|
"left join tenant_auth_microsoft tam on tam.tenant_id = t.id " .
|
|
"where u.active = 1 " .
|
|
"group by u.id, u.uuid, u.display_name, u.email " .
|
|
"having sum(case when coalesce(tam.enabled, 0) = 0 or coalesce(tam.enforce_microsoft_login, 0) = 0 then 1 else 0 end) = 0 " .
|
|
"order by u.display_name asc " .
|
|
"limit 10",
|
|
$tenantActiveStatus
|
|
);
|
|
$usersMicrosoftOnlyRows = is_array($usersMicrosoftOnlyRowsRaw) ? array_values(array_filter(array_map(
|
|
static function ($row) use ($flattenStatsRow): ?array {
|
|
$flat = $flattenStatsRow($row);
|
|
$userUuid = trim((string) ($flat['user_uuid'] ?? ($flat['uuid'] ?? '')));
|
|
$email = trim((string) ($flat['user_email'] ?? ($flat['email'] ?? '')));
|
|
if ($userUuid === '' || $email === '') {
|
|
return null;
|
|
}
|
|
return [
|
|
'user_uuid' => $userUuid,
|
|
'display_name' => trim((string) ($flat['user_display_name'] ?? ($flat['display_name'] ?? ''))),
|
|
'email' => $email,
|
|
'tenant_labels' => trim((string) ($flat['tenant_labels'] ?? ($flat['description'] ?? ''))),
|
|
];
|
|
},
|
|
$usersMicrosoftOnlyRowsRaw
|
|
))) : [];
|
|
|
|
$usersMicrosoftSyncGapRowsRaw = DB::select(
|
|
"select " .
|
|
" x.user_uuid, x.user_display_name, x.user_email, " .
|
|
" trim(concat_ws(', ', " .
|
|
" if(x.need_first_name = 1, 'first_name', null), " .
|
|
" if(x.need_last_name = 1, 'last_name', null), " .
|
|
" if(x.need_phone = 1, 'phone', null), " .
|
|
" if(x.need_mobile = 1, 'mobile', null) " .
|
|
" )) as configured_fields, " .
|
|
" trim(concat_ws(', ', " .
|
|
" if(x.need_first_name = 1 and x.first_name_value = '', 'first_name', null), " .
|
|
" if(x.need_last_name = 1 and x.last_name_value = '', 'last_name', null), " .
|
|
" if(x.need_phone = 1 and x.phone_value = '', 'phone', null), " .
|
|
" if(x.need_mobile = 1 and x.mobile_value = '', 'mobile', null) " .
|
|
" )) as missing_fields " .
|
|
"from ( " .
|
|
" select " .
|
|
" u.id, u.uuid as user_uuid, u.display_name as user_display_name, u.email as user_email, " .
|
|
" trim(coalesce(u.first_name, '')) as first_name_value, " .
|
|
" trim(coalesce(u.last_name, '')) as last_name_value, " .
|
|
" trim(coalesce(u.phone, '')) as phone_value, " .
|
|
" trim(coalesce(u.mobile, '')) as mobile_value, " .
|
|
" max(find_in_set('first_name', coalesce(nullif(tam.sync_profile_fields, ''), 'first_name,last_name')) > 0) as need_first_name, " .
|
|
" max(find_in_set('last_name', coalesce(nullif(tam.sync_profile_fields, ''), 'first_name,last_name')) > 0) as need_last_name, " .
|
|
" max(find_in_set('phone', coalesce(nullif(tam.sync_profile_fields, ''), 'first_name,last_name')) > 0) as need_phone, " .
|
|
" max(find_in_set('mobile', coalesce(nullif(tam.sync_profile_fields, ''), 'first_name,last_name')) > 0) as need_mobile " .
|
|
" from users u " .
|
|
" join user_tenants ut on ut.user_id = u.id " .
|
|
" join tenants t on t.id = ut.tenant_id and t.status = ? " .
|
|
" join tenant_auth_microsoft tam on tam.tenant_id = t.id " .
|
|
" where u.active = 1 and u.last_login_provider = 'microsoft' and tam.enabled = 1 and tam.sync_profile_on_login = 1 " .
|
|
" group by u.id, u.uuid, u.display_name, u.email, u.first_name, u.last_name, u.phone, u.mobile " .
|
|
") x " .
|
|
"where (x.need_first_name = 1 and x.first_name_value = '') " .
|
|
" or (x.need_last_name = 1 and x.last_name_value = '') " .
|
|
" or (x.need_phone = 1 and x.phone_value = '') " .
|
|
" or (x.need_mobile = 1 and x.mobile_value = '') " .
|
|
"order by x.user_display_name asc " .
|
|
"limit 10",
|
|
$tenantActiveStatus
|
|
);
|
|
$usersMicrosoftSyncGapRows = is_array($usersMicrosoftSyncGapRowsRaw) ? array_values(array_filter(array_map(
|
|
static function ($row) use ($flattenStatsRow): ?array {
|
|
$flat = $flattenStatsRow($row);
|
|
$userUuid = trim((string) ($flat['user_uuid'] ?? ($flat['uuid'] ?? '')));
|
|
$email = trim((string) ($flat['user_email'] ?? ($flat['email'] ?? '')));
|
|
if ($userUuid === '' || $email === '') {
|
|
return null;
|
|
}
|
|
return [
|
|
'user_uuid' => $userUuid,
|
|
'display_name' => trim((string) ($flat['user_display_name'] ?? ($flat['display_name'] ?? ''))),
|
|
'email' => $email,
|
|
'configured_fields' => trim((string) ($flat['configured_fields'] ?? '')),
|
|
'missing_fields' => trim((string) ($flat['missing_fields'] ?? '')),
|
|
];
|
|
},
|
|
$usersMicrosoftSyncGapRowsRaw
|
|
))) : [];
|
|
}
|
|
|
|
$customFieldStatsAvailable = (int) (DB::selectValue(
|
|
"select count(*) from information_schema.tables " .
|
|
"where table_schema = database() " .
|
|
"and table_name in ('tenant_custom_field_definitions', 'tenant_custom_field_options', 'user_custom_field_values')"
|
|
) ?? 0) === 3;
|
|
$customFieldsSelectionWithoutOptionsCount = 0;
|
|
$customFieldTenantsWithSelectionWithoutOptionsCount = 0;
|
|
$customFieldsInactiveWithValuesCount = 0;
|
|
$customFieldsUnusedActiveCount = 0;
|
|
$customFieldsSelectionWithoutOptionsHref = 'admin/tenants';
|
|
$customFieldTenantsWithSelectionWithoutOptionsHref = 'admin/tenants';
|
|
$customFieldsInactiveWithValuesHref = 'admin/tenants';
|
|
$customFieldsUnusedActiveHref = 'admin/tenants';
|
|
$customFieldIssueTenantRows = [];
|
|
|
|
if ($customFieldStatsAvailable) {
|
|
$loadCustomFieldIssueTenants = static function (string $query): array {
|
|
$rows = DB::select($query);
|
|
if (!is_array($rows)) {
|
|
return [];
|
|
}
|
|
$items = [];
|
|
foreach ($rows as $row) {
|
|
$tenant = $row['tenants'] ?? [];
|
|
$meta = $row[''] ?? [];
|
|
$tenantUuid = trim((string) ($tenant['uuid'] ?? ''));
|
|
$tenantDescription = trim((string) ($tenant['description'] ?? ''));
|
|
$issueCount = (int) ($meta['issue_count'] ?? 0);
|
|
if ($tenantUuid === '' || $tenantDescription === '' || $issueCount <= 0) {
|
|
continue;
|
|
}
|
|
$items[] = [
|
|
'tenant_uuid' => $tenantUuid,
|
|
'tenant_description' => $tenantDescription,
|
|
'issue_count' => $issueCount,
|
|
];
|
|
}
|
|
return $items;
|
|
};
|
|
|
|
$customFieldsSelectionWithoutOptionsCount = (int) (DB::selectValue(
|
|
"select count(*) from ( " .
|
|
"select d.id " .
|
|
"from tenant_custom_field_definitions d " .
|
|
"left join tenant_custom_field_options o on o.definition_id = d.id and o.active = 1 " .
|
|
"where d.active = 1 and d.type in ('select', 'multiselect') " .
|
|
"group by d.id " .
|
|
"having count(o.id) = 0 " .
|
|
') missing_options'
|
|
) ?? 0);
|
|
|
|
$customFieldTenantsWithSelectionWithoutOptionsCount = (int) (DB::selectValue(
|
|
"select count(distinct missing_options.tenant_id) from ( " .
|
|
"select d.tenant_id " .
|
|
"from tenant_custom_field_definitions d " .
|
|
"left join tenant_custom_field_options o on o.definition_id = d.id and o.active = 1 " .
|
|
"where d.active = 1 and d.type in ('select', 'multiselect') " .
|
|
"group by d.id, d.tenant_id " .
|
|
"having count(o.id) = 0 " .
|
|
') missing_options'
|
|
) ?? 0);
|
|
|
|
$customFieldsInactiveWithValuesCount = (int) (DB::selectValue(
|
|
'select count(distinct d.id) from tenant_custom_field_definitions d ' .
|
|
'inner join user_custom_field_values v on v.definition_id = d.id ' .
|
|
'where d.active = 0'
|
|
) ?? 0);
|
|
|
|
$customFieldsUnusedActiveCount = (int) (DB::selectValue(
|
|
"select count(*) from ( " .
|
|
"select d.id " .
|
|
"from tenant_custom_field_definitions d " .
|
|
"left join user_custom_field_values v on v.definition_id = d.id " .
|
|
"where d.active = 1 " .
|
|
"group by d.id " .
|
|
"having count(v.id) = 0 " .
|
|
') unused_active'
|
|
) ?? 0);
|
|
|
|
$selectionWithoutOptionsTenantUuid = (string) (DB::selectValue(
|
|
"select t.uuid from tenants t " .
|
|
"inner join ( " .
|
|
"select d.tenant_id " .
|
|
"from tenant_custom_field_definitions d " .
|
|
"left join tenant_custom_field_options o on o.definition_id = d.id and o.active = 1 " .
|
|
"where d.active = 1 and d.type in ('select', 'multiselect') " .
|
|
"group by d.id, d.tenant_id " .
|
|
"having count(o.id) = 0 " .
|
|
"order by d.tenant_id asc " .
|
|
"limit 1" .
|
|
') issue on issue.tenant_id = t.id ' .
|
|
'limit 1'
|
|
) ?? '');
|
|
|
|
$inactiveWithValuesTenantUuid = (string) (DB::selectValue(
|
|
"select t.uuid from tenants t " .
|
|
"inner join ( " .
|
|
"select d.tenant_id " .
|
|
"from tenant_custom_field_definitions d " .
|
|
"inner join user_custom_field_values v on v.definition_id = d.id " .
|
|
"where d.active = 0 " .
|
|
"group by d.tenant_id " .
|
|
"order by d.tenant_id asc " .
|
|
"limit 1" .
|
|
') issue on issue.tenant_id = t.id ' .
|
|
'limit 1'
|
|
) ?? '');
|
|
|
|
$unusedActiveTenantUuid = (string) (DB::selectValue(
|
|
"select t.uuid from tenants t " .
|
|
"inner join ( " .
|
|
"select d.tenant_id " .
|
|
"from tenant_custom_field_definitions d " .
|
|
"left join user_custom_field_values v on v.definition_id = d.id " .
|
|
"where d.active = 1 " .
|
|
"group by d.id, d.tenant_id " .
|
|
"having count(v.id) = 0 " .
|
|
"order by d.tenant_id asc " .
|
|
"limit 1" .
|
|
') issue on issue.tenant_id = t.id ' .
|
|
'limit 1'
|
|
) ?? '');
|
|
|
|
if ($selectionWithoutOptionsTenantUuid !== '') {
|
|
$customFieldsSelectionWithoutOptionsHref = 'admin/tenants/edit/' . $selectionWithoutOptionsTenantUuid . '?tab=custom_fields';
|
|
$customFieldTenantsWithSelectionWithoutOptionsHref = $customFieldsSelectionWithoutOptionsHref;
|
|
}
|
|
if ($inactiveWithValuesTenantUuid !== '') {
|
|
$customFieldsInactiveWithValuesHref = 'admin/tenants/edit/' . $inactiveWithValuesTenantUuid . '?tab=custom_fields';
|
|
}
|
|
if ($unusedActiveTenantUuid !== '') {
|
|
$customFieldsUnusedActiveHref = 'admin/tenants/edit/' . $unusedActiveTenantUuid . '?tab=custom_fields';
|
|
}
|
|
|
|
$selectionIssueTenants = $loadCustomFieldIssueTenants(
|
|
"select t.uuid, t.description, count(*) as issue_count " .
|
|
"from tenants t " .
|
|
"inner join ( " .
|
|
"select d.id, d.tenant_id " .
|
|
"from tenant_custom_field_definitions d " .
|
|
"left join tenant_custom_field_options o on o.definition_id = d.id and o.active = 1 " .
|
|
"where d.active = 1 and d.type in ('select', 'multiselect') " .
|
|
"group by d.id, d.tenant_id " .
|
|
"having count(o.id) = 0 " .
|
|
") issue on issue.tenant_id = t.id " .
|
|
"group by t.id, t.uuid, t.description " .
|
|
"order by issue_count desc, t.description asc " .
|
|
"limit 5"
|
|
);
|
|
foreach ($selectionIssueTenants as $row) {
|
|
$customFieldIssueTenantRows[] = [
|
|
'issue' => t('Selection fields without options'),
|
|
'tenant_uuid' => $row['tenant_uuid'],
|
|
'tenant_description' => $row['tenant_description'],
|
|
'issue_count' => $row['issue_count'],
|
|
];
|
|
}
|
|
|
|
$inactiveIssueTenants = $loadCustomFieldIssueTenants(
|
|
"select t.uuid, t.description, count(distinct d.id) as issue_count " .
|
|
"from tenants t " .
|
|
"inner join tenant_custom_field_definitions d on d.tenant_id = t.id and d.active = 0 " .
|
|
"inner join user_custom_field_values v on v.definition_id = d.id " .
|
|
"group by t.id, t.uuid, t.description " .
|
|
"order by issue_count desc, t.description asc " .
|
|
"limit 5"
|
|
);
|
|
foreach ($inactiveIssueTenants as $row) {
|
|
$customFieldIssueTenantRows[] = [
|
|
'issue' => t('Inactive custom fields with values'),
|
|
'tenant_uuid' => $row['tenant_uuid'],
|
|
'tenant_description' => $row['tenant_description'],
|
|
'issue_count' => $row['issue_count'],
|
|
];
|
|
}
|
|
|
|
$unusedIssueTenants = $loadCustomFieldIssueTenants(
|
|
"select t.uuid, t.description, count(*) as issue_count " .
|
|
"from tenants t " .
|
|
"inner join ( " .
|
|
"select d.id, d.tenant_id " .
|
|
"from tenant_custom_field_definitions d " .
|
|
"left join user_custom_field_values v on v.definition_id = d.id " .
|
|
"where d.active = 1 " .
|
|
"group by d.id, d.tenant_id " .
|
|
"having count(v.id) = 0 " .
|
|
") issue on issue.tenant_id = t.id " .
|
|
"group by t.id, t.uuid, t.description " .
|
|
"order by issue_count desc, t.description asc " .
|
|
"limit 5"
|
|
);
|
|
foreach ($unusedIssueTenants as $row) {
|
|
$customFieldIssueTenantRows[] = [
|
|
'issue' => t('Unused active custom fields'),
|
|
'tenant_uuid' => $row['tenant_uuid'],
|
|
'tenant_description' => $row['tenant_description'],
|
|
'issue_count' => $row['issue_count'],
|
|
];
|
|
}
|
|
}
|
|
|
|
$apiStatsAvailable = (int) (DB::selectValue(
|
|
"select count(*) from information_schema.tables " .
|
|
"where table_schema = database() and table_name in ('api_audit_log', 'user_api_tokens')"
|
|
) ?? 0) === 2;
|
|
$apiRequests24hCount = 0;
|
|
$apiRequestsPrev24hCount = 0;
|
|
$apiRequestTrendPercent = 0.0;
|
|
$apiErrors4xx24hCount = 0;
|
|
$apiErrors5xx24hCount = 0;
|
|
$apiP95DurationMs = 0;
|
|
$apiTokensExpiring7dCount = 0;
|
|
$apiTopErrorRows = [];
|
|
$apiSlowEndpointRows = [];
|
|
|
|
if ($apiStatsAvailable) {
|
|
$apiRequests24hCount = (int) (DB::selectValue(
|
|
'select count(*) from api_audit_log where created_at >= (NOW() - INTERVAL 24 HOUR)'
|
|
) ?? 0);
|
|
$apiRequestsPrev24hCount = (int) (DB::selectValue(
|
|
'select count(*) from api_audit_log where created_at >= (NOW() - INTERVAL 48 HOUR) and created_at < (NOW() - INTERVAL 24 HOUR)'
|
|
) ?? 0);
|
|
if ($apiRequestsPrev24hCount > 0) {
|
|
$apiRequestTrendPercent = round((($apiRequests24hCount - $apiRequestsPrev24hCount) * 100) / $apiRequestsPrev24hCount, 1);
|
|
}
|
|
|
|
$apiErrors4xx24hCount = (int) (DB::selectValue(
|
|
'select count(*) from api_audit_log where created_at >= (NOW() - INTERVAL 24 HOUR) and status_code between 400 and 499'
|
|
) ?? 0);
|
|
$apiErrors5xx24hCount = (int) (DB::selectValue(
|
|
'select count(*) from api_audit_log where created_at >= (NOW() - INTERVAL 24 HOUR) and status_code between 500 and 599'
|
|
) ?? 0);
|
|
|
|
$durationCount = (int) (DB::selectValue(
|
|
'select count(*) from api_audit_log where created_at >= (NOW() - INTERVAL 24 HOUR) and status_code between 200 and 299 and duration_ms is not null'
|
|
) ?? 0);
|
|
if ($durationCount > 0) {
|
|
$offset = (int) floor(($durationCount - 1) * 0.95);
|
|
$apiP95DurationMs = (int) (DB::selectValue(
|
|
'select duration_ms from api_audit_log ' .
|
|
'where created_at >= (NOW() - INTERVAL 24 HOUR) and status_code between 200 and 299 and duration_ms is not null ' .
|
|
'order by duration_ms asc limit ' . $offset . ', 1'
|
|
) ?? 0);
|
|
}
|
|
|
|
$apiTokensExpiring7dCount = (int) (DB::selectValue(
|
|
'select count(*) from user_api_tokens ' .
|
|
'where revoked_at is null and expires_at is not null and expires_at > NOW() and expires_at <= (NOW() + INTERVAL 7 DAY)'
|
|
) ?? 0);
|
|
|
|
$apiTopErrorRowsRaw = DB::select(
|
|
"select " .
|
|
" coalesce(nullif(error_code, ''), concat('http_', status_code)) as error_label, " .
|
|
" count(*) as hit_count " .
|
|
"from api_audit_log " .
|
|
"where created_at >= (NOW() - INTERVAL 24 HOUR) and status_code >= 400 " .
|
|
"group by error_label " .
|
|
"order by hit_count desc, error_label asc " .
|
|
"limit 5"
|
|
);
|
|
$apiTopErrorRows = is_array($apiTopErrorRowsRaw) ? array_values(array_filter(array_map(
|
|
static function ($row) use ($flattenStatsRow): ?array {
|
|
$flat = $flattenStatsRow($row);
|
|
$label = trim((string) ($flat['error_label'] ?? ''));
|
|
$hits = (int) ($flat['hit_count'] ?? 0);
|
|
if ($label === '' || $hits <= 0) {
|
|
return null;
|
|
}
|
|
return [
|
|
'error_label' => $label,
|
|
'hit_count' => $hits,
|
|
];
|
|
},
|
|
$apiTopErrorRowsRaw
|
|
))) : [];
|
|
|
|
$apiSlowEndpointRowsRaw = DB::select(
|
|
"select " .
|
|
" path, " .
|
|
" count(*) as hit_count, " .
|
|
" round(avg(duration_ms), 1) as avg_duration_ms, " .
|
|
" max(duration_ms) as max_duration_ms " .
|
|
"from api_audit_log " .
|
|
"where created_at >= (NOW() - INTERVAL 24 HOUR) and status_code between 200 and 299 and duration_ms is not null " .
|
|
"group by path " .
|
|
"having count(*) >= 3 " .
|
|
"order by avg(duration_ms) desc, max(duration_ms) desc " .
|
|
"limit 5"
|
|
);
|
|
$apiSlowEndpointRows = is_array($apiSlowEndpointRowsRaw) ? array_values(array_filter(array_map(
|
|
static function ($row) use ($flattenStatsRow): ?array {
|
|
$flat = $flattenStatsRow($row);
|
|
$path = trim((string) ($flat['path'] ?? ''));
|
|
$hits = (int) ($flat['hit_count'] ?? 0);
|
|
if ($path === '' || $hits <= 0) {
|
|
return null;
|
|
}
|
|
return [
|
|
'path' => $path,
|
|
'hit_count' => $hits,
|
|
'avg_duration_ms' => (float) ($flat['avg_duration_ms'] ?? 0),
|
|
'max_duration_ms' => (int) ($flat['max_duration_ms'] ?? 0),
|
|
];
|
|
},
|
|
$apiSlowEndpointRowsRaw
|
|
))) : [];
|
|
}
|
|
|
|
$importStatsAvailable = (int) (DB::selectValue(
|
|
"select count(*) from information_schema.tables " .
|
|
"where table_schema = database() and table_name = 'import_audit_runs'"
|
|
) ?? 0) === 1;
|
|
$importRuns7dCount = 0;
|
|
$importRunsPrev7dCount = 0;
|
|
$importRunTrendPercent = 0.0;
|
|
$importRowsCreated7dCount = 0;
|
|
$importRowsFailed7dCount = 0;
|
|
$importPartialOrFailedRuns7dCount = 0;
|
|
$importSuccessRate7dPercent = 0.0;
|
|
$importAverageDurationMs7d = 0;
|
|
$importRecentRunRows = [];
|
|
$importProfileRows = [];
|
|
$importTopErrorCodeRows = [];
|
|
|
|
if ($importStatsAvailable) {
|
|
$importRuns7dCount = (int) (DB::selectValue(
|
|
"select count(*) from import_audit_runs where started_at >= (NOW() - INTERVAL 7 DAY)"
|
|
) ?? 0);
|
|
$importRunsPrev7dCount = (int) (DB::selectValue(
|
|
"select count(*) from import_audit_runs where started_at >= (NOW() - INTERVAL 14 DAY) and started_at < (NOW() - INTERVAL 7 DAY)"
|
|
) ?? 0);
|
|
if ($importRunsPrev7dCount > 0) {
|
|
$importRunTrendPercent = round((($importRuns7dCount - $importRunsPrev7dCount) * 100) / $importRunsPrev7dCount, 1);
|
|
}
|
|
|
|
$importRowsCreated7dCount = (int) (DB::selectValue(
|
|
"select coalesce(sum(created_count), 0) from import_audit_runs where started_at >= (NOW() - INTERVAL 7 DAY)"
|
|
) ?? 0);
|
|
$importRowsFailed7dCount = (int) (DB::selectValue(
|
|
"select coalesce(sum(failed_count), 0) from import_audit_runs where started_at >= (NOW() - INTERVAL 7 DAY)"
|
|
) ?? 0);
|
|
$importPartialOrFailedRuns7dCount = (int) (DB::selectValue(
|
|
"select count(*) from import_audit_runs where started_at >= (NOW() - INTERVAL 7 DAY) and status in (???)",
|
|
[$importStatusPartial, $importStatusFailed]
|
|
) ?? 0);
|
|
$importAverageDurationMs7d = (int) (DB::selectValue(
|
|
"select round(avg(duration_ms), 0) from import_audit_runs " .
|
|
"where started_at >= (NOW() - INTERVAL 7 DAY) and duration_ms is not null"
|
|
) ?? 0);
|
|
$importSuccessRuns7dCount = (int) (DB::selectValue(
|
|
"select count(*) from import_audit_runs where started_at >= (NOW() - INTERVAL 7 DAY) and status = ?",
|
|
$importStatusSuccess
|
|
) ?? 0);
|
|
if ($importRuns7dCount > 0) {
|
|
$importSuccessRate7dPercent = round(($importSuccessRuns7dCount * 100) / $importRuns7dCount, 1);
|
|
}
|
|
|
|
$importRecentRunRowsRaw = DB::select(
|
|
"select " .
|
|
" import_audit_runs.id, " .
|
|
" import_audit_runs.started_at, " .
|
|
" import_audit_runs.profile_key, " .
|
|
" import_audit_runs.status, " .
|
|
" import_audit_runs.rows_total, " .
|
|
" import_audit_runs.created_count, " .
|
|
" import_audit_runs.failed_count, " .
|
|
" import_audit_runs.duration_ms, " .
|
|
" users.uuid as user_uuid, " .
|
|
" users.display_name as user_display_name, " .
|
|
" users.email as user_email " .
|
|
"from import_audit_runs " .
|
|
"left join users on users.id = import_audit_runs.user_id " .
|
|
"order by import_audit_runs.started_at desc " .
|
|
"limit 10"
|
|
);
|
|
$importRecentRunRows = is_array($importRecentRunRowsRaw) ? array_values(array_filter(array_map(
|
|
static function ($row) use ($flattenStatsRow): ?array {
|
|
$flat = $flattenStatsRow($row);
|
|
$id = (int) ($flat['id'] ?? 0);
|
|
$startedAt = trim((string) ($flat['started_at'] ?? ''));
|
|
$profileKey = trim((string) ($flat['profile_key'] ?? ''));
|
|
$status = trim((string) ($flat['status'] ?? ''));
|
|
if ($id <= 0 || $startedAt === '' || $profileKey === '' || $status === '') {
|
|
return null;
|
|
}
|
|
return [
|
|
'id' => $id,
|
|
'started_at' => $startedAt,
|
|
'profile_key' => $profileKey,
|
|
'status' => $status,
|
|
'rows_total' => (int) ($flat['rows_total'] ?? 0),
|
|
'created_count' => (int) ($flat['created_count'] ?? 0),
|
|
'failed_count' => (int) ($flat['failed_count'] ?? 0),
|
|
'duration_ms' => (int) ($flat['duration_ms'] ?? 0),
|
|
'user_uuid' => trim((string) ($flat['user_uuid'] ?? '')),
|
|
'user_display_name' => trim((string) ($flat['user_display_name'] ?? '')),
|
|
'user_email' => trim((string) ($flat['user_email'] ?? '')),
|
|
];
|
|
},
|
|
$importRecentRunRowsRaw
|
|
))) : [];
|
|
|
|
$importProfileRowsRaw = DB::select(
|
|
"select " .
|
|
" profile_key, " .
|
|
" count(*) as run_count, " .
|
|
" sum(case when status = ? then 1 else 0 end) as success_count, " .
|
|
" coalesce(sum(created_count), 0) as created_count_sum, " .
|
|
" coalesce(sum(failed_count), 0) as failed_count_sum " .
|
|
"from import_audit_runs " .
|
|
"where started_at >= (NOW() - INTERVAL 7 DAY) " .
|
|
"group by profile_key " .
|
|
"order by run_count desc, profile_key asc " .
|
|
"limit 10",
|
|
$importStatusSuccess
|
|
);
|
|
$importProfileRows = is_array($importProfileRowsRaw) ? array_values(array_filter(array_map(
|
|
static function ($row) use ($flattenStatsRow): ?array {
|
|
$flat = $flattenStatsRow($row);
|
|
$profileKey = trim((string) ($flat['profile_key'] ?? ''));
|
|
$runCount = (int) ($flat['run_count'] ?? 0);
|
|
if ($profileKey === '' || $runCount <= 0) {
|
|
return null;
|
|
}
|
|
$successCount = (int) ($flat['success_count'] ?? 0);
|
|
$successRate = round(($successCount * 100) / $runCount, 1);
|
|
return [
|
|
'profile_key' => $profileKey,
|
|
'run_count' => $runCount,
|
|
'created_count_sum' => (int) ($flat['created_count_sum'] ?? 0),
|
|
'failed_count_sum' => (int) ($flat['failed_count_sum'] ?? 0),
|
|
'success_rate' => $successRate,
|
|
];
|
|
},
|
|
$importProfileRowsRaw
|
|
))) : [];
|
|
|
|
$importTopErrorRowsRaw = DB::select(
|
|
"select error_codes_json from import_audit_runs " .
|
|
"where started_at >= (NOW() - INTERVAL 7 DAY) " .
|
|
"and error_codes_json is not null and error_codes_json <> '' " .
|
|
"order by started_at desc " .
|
|
"limit 1000"
|
|
);
|
|
$errorCodeCounts = [];
|
|
if (is_array($importTopErrorRowsRaw)) {
|
|
foreach ($importTopErrorRowsRaw as $row) {
|
|
$flat = $flattenStatsRow($row);
|
|
$json = (string) ($flat['error_codes_json'] ?? '');
|
|
if ($json === '') {
|
|
continue;
|
|
}
|
|
$decoded = json_decode($json, true);
|
|
if (!is_array($decoded)) {
|
|
continue;
|
|
}
|
|
foreach ($decoded as $code => $count) {
|
|
$codeKey = trim((string) $code);
|
|
$countValue = (int) $count;
|
|
if ($codeKey === '' || $countValue <= 0) {
|
|
continue;
|
|
}
|
|
$errorCodeCounts[$codeKey] = (int) ($errorCodeCounts[$codeKey] ?? 0) + $countValue;
|
|
}
|
|
}
|
|
}
|
|
if (!empty($errorCodeCounts)) {
|
|
arsort($errorCodeCounts);
|
|
foreach (array_slice($errorCodeCounts, 0, 5, true) as $code => $count) {
|
|
$importTopErrorCodeRows[] = [
|
|
'error_code' => $code,
|
|
'count' => (int) $count,
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
$auditNow = new \DateTimeImmutable('now');
|
|
$systemAuditCreatedFrom = $auditNow->modify('-1 day')->format('Y-m-d');
|
|
$systemAuditCreatedTo = $auditNow->format('Y-m-d');
|
|
$lifecycleCreatedFrom = $auditNow->modify('-7 days')->format('Y-m-d');
|
|
$lifecycleCreatedTo = $auditNow->format('Y-m-d');
|
|
|
|
$systemAuditStatsAvailable = (int) (DB::selectValue(
|
|
"select count(*) from information_schema.tables " .
|
|
"where table_schema = database() and table_name = 'system_audit_log'"
|
|
) ?? 0) === 1;
|
|
$userLifecycleAuditStatsAvailable = (int) (DB::selectValue(
|
|
"select count(*) from information_schema.tables " .
|
|
"where table_schema = database() and table_name = 'user_lifecycle_audit_log'"
|
|
) ?? 0) === 1;
|
|
$auditStatsAvailable = $systemAuditStatsAvailable || $userLifecycleAuditStatsAvailable;
|
|
|
|
$systemAuditEvents24hCount = 0;
|
|
$systemAuditFailed24hCount = 0;
|
|
$systemAuditDenied24hCount = 0;
|
|
$systemAuditEventsPrev24hCount = 0;
|
|
$systemAuditTrendPercent = 0.0;
|
|
$systemAuditTopEventTypeRows = [];
|
|
$systemAuditRecentRiskRows = [];
|
|
$systemAuditAll24hHref = 'admin/system-audit';
|
|
$systemAuditFailed24hHref = 'admin/system-audit';
|
|
$systemAuditDenied24hHref = 'admin/system-audit';
|
|
|
|
if ($systemAuditStatsAvailable) {
|
|
$systemAuditEvents24hCount = (int) (DB::selectValue(
|
|
"select count(*) from system_audit_log where created_at >= (NOW() - INTERVAL 24 HOUR)"
|
|
) ?? 0);
|
|
$systemAuditFailed24hCount = (int) (DB::selectValue(
|
|
"select count(*) from system_audit_log " .
|
|
"where created_at >= (NOW() - INTERVAL 24 HOUR) and outcome = ?",
|
|
$systemAuditOutcomeFailed
|
|
) ?? 0);
|
|
$systemAuditDenied24hCount = (int) (DB::selectValue(
|
|
"select count(*) from system_audit_log " .
|
|
"where created_at >= (NOW() - INTERVAL 24 HOUR) and outcome = ?",
|
|
$systemAuditOutcomeDenied
|
|
) ?? 0);
|
|
$systemAuditEventsPrev24hCount = (int) (DB::selectValue(
|
|
"select count(*) from system_audit_log " .
|
|
"where created_at >= (NOW() - INTERVAL 48 HOUR) and created_at < (NOW() - INTERVAL 24 HOUR)"
|
|
) ?? 0);
|
|
if ($systemAuditEventsPrev24hCount > 0) {
|
|
$systemAuditTrendPercent = round(
|
|
(($systemAuditEvents24hCount - $systemAuditEventsPrev24hCount) * 100) / $systemAuditEventsPrev24hCount,
|
|
1
|
|
);
|
|
}
|
|
|
|
$systemAuditAll24hHref = 'admin/system-audit?' . http_build_query([
|
|
'created_from' => $systemAuditCreatedFrom,
|
|
'created_to' => $systemAuditCreatedTo,
|
|
]);
|
|
$systemAuditFailed24hHref = 'admin/system-audit?' . http_build_query([
|
|
'outcome' => $systemAuditOutcomeFailed,
|
|
'created_from' => $systemAuditCreatedFrom,
|
|
'created_to' => $systemAuditCreatedTo,
|
|
]);
|
|
$systemAuditDenied24hHref = 'admin/system-audit?' . http_build_query([
|
|
'outcome' => $systemAuditOutcomeDenied,
|
|
'created_from' => $systemAuditCreatedFrom,
|
|
'created_to' => $systemAuditCreatedTo,
|
|
]);
|
|
|
|
$systemAuditTopEventTypeRowsRaw = DB::select(
|
|
"select " .
|
|
" event_type, " .
|
|
" count(*) as hit_count, " .
|
|
" sum(case when outcome in (???) then 1 else 0 end) as failed_or_denied_count " .
|
|
"from system_audit_log " .
|
|
"where created_at >= (NOW() - INTERVAL 24 HOUR) " .
|
|
"group by event_type " .
|
|
"order by failed_or_denied_count desc, hit_count desc, event_type asc " .
|
|
"limit 5",
|
|
[$systemAuditOutcomeFailed, $systemAuditOutcomeDenied]
|
|
);
|
|
$systemAuditTopEventTypeRows = is_array($systemAuditTopEventTypeRowsRaw) ? array_values(array_filter(array_map(
|
|
static function ($row) use ($flattenStatsRow): ?array {
|
|
$flat = $flattenStatsRow($row);
|
|
$eventType = trim((string) ($flat['event_type'] ?? ''));
|
|
$hitCount = (int) ($flat['hit_count'] ?? 0);
|
|
if ($eventType === '' || $hitCount <= 0) {
|
|
return null;
|
|
}
|
|
return [
|
|
'event_type' => $eventType,
|
|
'hit_count' => $hitCount,
|
|
'failed_or_denied_count' => (int) ($flat['failed_or_denied_count'] ?? 0),
|
|
];
|
|
},
|
|
$systemAuditTopEventTypeRowsRaw
|
|
))) : [];
|
|
|
|
$systemAuditRecentRiskRowsRaw = DB::select(
|
|
"select " .
|
|
" id, event_type, outcome, channel, created_at " .
|
|
"from system_audit_log " .
|
|
"where created_at >= (NOW() - INTERVAL 24 HOUR) and outcome in (???) " .
|
|
"order by created_at desc " .
|
|
"limit 10",
|
|
[$systemAuditOutcomeFailed, $systemAuditOutcomeDenied]
|
|
);
|
|
$systemAuditRecentRiskRows = is_array($systemAuditRecentRiskRowsRaw) ? array_values(array_filter(array_map(
|
|
static function ($row) use ($flattenStatsRow): ?array {
|
|
$flat = $flattenStatsRow($row);
|
|
$id = (int) ($flat['id'] ?? 0);
|
|
$eventType = trim((string) ($flat['event_type'] ?? ''));
|
|
$outcome = trim((string) ($flat['outcome'] ?? ''));
|
|
if ($id <= 0 || $eventType === '' || $outcome === '') {
|
|
return null;
|
|
}
|
|
return [
|
|
'id' => $id,
|
|
'event_type' => $eventType,
|
|
'outcome' => $outcome,
|
|
'channel' => trim((string) ($flat['channel'] ?? '')),
|
|
'created_at' => trim((string) ($flat['created_at'] ?? '')),
|
|
];
|
|
},
|
|
$systemAuditRecentRiskRowsRaw
|
|
))) : [];
|
|
}
|
|
|
|
$lifecycleRuns7dCount = 0;
|
|
$lifecycleRunsPrev7dCount = 0;
|
|
$lifecycleRunTrendPercent = 0.0;
|
|
$lifecycleFailed7dCount = 0;
|
|
$lifecycleSkipped7dCount = 0;
|
|
$lifecycleRestore7dCount = 0;
|
|
$lifecycleTopReasonRows = [];
|
|
$lifecycleRecentRiskRows = [];
|
|
$lifecycleAll7dHref = 'admin/user-lifecycle-audit';
|
|
$lifecycleFailed7dHref = 'admin/user-lifecycle-audit';
|
|
$lifecycleRestore7dHref = 'admin/user-lifecycle-audit';
|
|
|
|
if ($userLifecycleAuditStatsAvailable) {
|
|
$lifecycleRuns7dCount = (int) (DB::selectValue(
|
|
"select count(*) from user_lifecycle_audit_log where created_at >= (NOW() - INTERVAL 7 DAY)"
|
|
) ?? 0);
|
|
$lifecycleRunsPrev7dCount = (int) (DB::selectValue(
|
|
"select count(*) from user_lifecycle_audit_log " .
|
|
"where created_at >= (NOW() - INTERVAL 14 DAY) and created_at < (NOW() - INTERVAL 7 DAY)"
|
|
) ?? 0);
|
|
if ($lifecycleRunsPrev7dCount > 0) {
|
|
$lifecycleRunTrendPercent = round(
|
|
(($lifecycleRuns7dCount - $lifecycleRunsPrev7dCount) * 100) / $lifecycleRunsPrev7dCount,
|
|
1
|
|
);
|
|
}
|
|
$lifecycleFailed7dCount = (int) (DB::selectValue(
|
|
"select count(*) from user_lifecycle_audit_log " .
|
|
"where created_at >= (NOW() - INTERVAL 7 DAY) and status = ?",
|
|
$lifecycleStatusFailed
|
|
) ?? 0);
|
|
$lifecycleSkipped7dCount = (int) (DB::selectValue(
|
|
"select count(*) from user_lifecycle_audit_log " .
|
|
"where created_at >= (NOW() - INTERVAL 7 DAY) and status = ?",
|
|
$lifecycleStatusSkipped
|
|
) ?? 0);
|
|
$lifecycleRestore7dCount = (int) (DB::selectValue(
|
|
"select count(*) from user_lifecycle_audit_log " .
|
|
"where created_at >= (NOW() - INTERVAL 7 DAY) and action = ?",
|
|
$lifecycleActionRestore
|
|
) ?? 0);
|
|
|
|
$lifecycleAll7dHref = 'admin/user-lifecycle-audit?' . http_build_query([
|
|
'created_from' => $lifecycleCreatedFrom,
|
|
'created_to' => $lifecycleCreatedTo,
|
|
]);
|
|
$lifecycleFailed7dHref = 'admin/user-lifecycle-audit?' . http_build_query([
|
|
'statuses' => $lifecycleStatusFailed . ',' . $lifecycleStatusSkipped,
|
|
'created_from' => $lifecycleCreatedFrom,
|
|
'created_to' => $lifecycleCreatedTo,
|
|
]);
|
|
$lifecycleRestore7dHref = 'admin/user-lifecycle-audit?' . http_build_query([
|
|
'actions' => $lifecycleActionRestore,
|
|
'created_from' => $lifecycleCreatedFrom,
|
|
'created_to' => $lifecycleCreatedTo,
|
|
]);
|
|
|
|
$lifecycleTopReasonRowsRaw = DB::select(
|
|
"select " .
|
|
" coalesce(nullif(reason_code, ''), '-') as reason_code, " .
|
|
" count(*) as hit_count " .
|
|
"from user_lifecycle_audit_log " .
|
|
"where created_at >= (NOW() - INTERVAL 7 DAY) " .
|
|
"group by reason_code " .
|
|
"order by hit_count desc, reason_code asc " .
|
|
"limit 5"
|
|
);
|
|
$lifecycleTopReasonRows = is_array($lifecycleTopReasonRowsRaw) ? array_values(array_filter(array_map(
|
|
static function ($row) use ($flattenStatsRow): ?array {
|
|
$flat = $flattenStatsRow($row);
|
|
$reasonCode = trim((string) ($flat['reason_code'] ?? '-'));
|
|
$hitCount = (int) ($flat['hit_count'] ?? 0);
|
|
if ($hitCount <= 0) {
|
|
return null;
|
|
}
|
|
if ($reasonCode === '') {
|
|
$reasonCode = '-';
|
|
}
|
|
return [
|
|
'reason_code' => $reasonCode,
|
|
'hit_count' => $hitCount,
|
|
];
|
|
},
|
|
$lifecycleTopReasonRowsRaw
|
|
))) : [];
|
|
|
|
$lifecycleRecentRiskRowsRaw = DB::select(
|
|
"select " .
|
|
" id, action, status, trigger_type, target_user_uuid, target_user_email, created_at " .
|
|
"from user_lifecycle_audit_log " .
|
|
"where created_at >= (NOW() - INTERVAL 7 DAY) " .
|
|
" and (status in (???) or action = ?) " .
|
|
"order by created_at desc " .
|
|
"limit 10",
|
|
[$lifecycleStatusFailed, $lifecycleStatusSkipped],
|
|
$lifecycleActionRestore
|
|
);
|
|
$lifecycleRecentRiskRows = is_array($lifecycleRecentRiskRowsRaw) ? array_values(array_filter(array_map(
|
|
static function ($row) use ($flattenStatsRow): ?array {
|
|
$flat = $flattenStatsRow($row);
|
|
$id = (int) ($flat['id'] ?? 0);
|
|
$action = trim((string) ($flat['action'] ?? ''));
|
|
$status = trim((string) ($flat['status'] ?? ''));
|
|
if ($id <= 0 || $action === '' || $status === '') {
|
|
return null;
|
|
}
|
|
return [
|
|
'id' => $id,
|
|
'action' => $action,
|
|
'status' => $status,
|
|
'trigger_type' => trim((string) ($flat['trigger_type'] ?? '')),
|
|
'target_user_uuid' => trim((string) ($flat['target_user_uuid'] ?? '')),
|
|
'target_user_email' => trim((string) ($flat['target_user_email'] ?? '')),
|
|
'created_at' => trim((string) ($flat['created_at'] ?? '')),
|
|
];
|
|
},
|
|
$lifecycleRecentRiskRowsRaw
|
|
))) : [];
|
|
}
|
|
|
|
$scheduledStatsAvailable = (int) (DB::selectValue(
|
|
"select count(*) from information_schema.tables " .
|
|
"where table_schema = database() and table_name in ('scheduled_jobs', 'scheduled_job_runs', 'scheduler_runtime_status')"
|
|
) ?? 0) === 3;
|
|
$scheduledEnabledJobsCount = 0;
|
|
$scheduledOverdueJobsCount = 0;
|
|
$scheduledRuns24hCount = 0;
|
|
$scheduledFailedRuns24hCount = 0;
|
|
$schedulerRunnerLastHeartbeatAt = '';
|
|
$schedulerRunnerLastResult = '';
|
|
$schedulerRunnerLastErrorCode = '';
|
|
$schedulerRunnerIsActive = false;
|
|
|
|
if ($scheduledStatsAvailable) {
|
|
$scheduledEnabledJobsCount = (int) (DB::selectValue(
|
|
"select count(*) from scheduled_jobs where enabled = 1"
|
|
) ?? 0);
|
|
$scheduledOverdueJobsCount = (int) (DB::selectValue(
|
|
"select count(*) from scheduled_jobs " .
|
|
"where enabled = 1 and next_run_at is not null and next_run_at <= NOW()"
|
|
) ?? 0);
|
|
$scheduledRuns24hCount = (int) (DB::selectValue(
|
|
"select count(*) from scheduled_job_runs " .
|
|
"where trigger_type = ? and started_at >= (NOW() - INTERVAL 24 HOUR)",
|
|
$schedulerTriggerScheduler
|
|
) ?? 0);
|
|
$scheduledFailedRuns24hCount = (int) (DB::selectValue(
|
|
"select count(*) from scheduled_job_runs " .
|
|
"where trigger_type = ? and status = ? and started_at >= (NOW() - INTERVAL 24 HOUR)",
|
|
$schedulerTriggerScheduler,
|
|
$schedulerRunStatusFailed
|
|
) ?? 0);
|
|
|
|
$schedulerRunnerIsActive = (int) (DB::selectValue(
|
|
"select case when last_heartbeat_at >= (NOW() - INTERVAL 3 MINUTE) then 1 else 0 end " .
|
|
"from scheduler_runtime_status where id = 1"
|
|
) ?? 0) === 1;
|
|
|
|
$schedulerRuntimeRowRaw = DB::selectOne(
|
|
"select last_heartbeat_at, last_result, last_error_code from scheduler_runtime_status where id = 1 limit 1"
|
|
);
|
|
$schedulerRuntimeRow = $flattenStatsRow($schedulerRuntimeRowRaw);
|
|
$schedulerRunnerLastHeartbeatAt = trim((string) ($schedulerRuntimeRow['last_heartbeat_at'] ?? ''));
|
|
$schedulerRunnerLastResult = trim((string) ($schedulerRuntimeRow['last_result'] ?? ''));
|
|
$schedulerRunnerLastErrorCode = trim((string) ($schedulerRuntimeRow['last_error_code'] ?? ''));
|
|
}
|
|
|
|
$usersUnverifiedCount = (int) (DB::selectValue(
|
|
'select count(*) from users where active = 1 and email_verified_at is null'
|
|
) ?? 0);
|
|
$usersNeverLoggedCount = (int) (DB::selectValue(
|
|
'select count(*) from users where active = 1 and last_login_at is null'
|
|
) ?? 0);
|
|
$recentLogins = DB::select(
|
|
'select id, uuid, first_name, last_name, email, last_login_at from users where active = 1 and last_login_at is not null order by last_login_at desc limit 10'
|
|
);
|
|
$recentLogins = is_array($recentLogins) ? array_map(
|
|
static fn ($row) => $row['users'] ?? $row,
|
|
$recentLogins
|
|
) : [];
|
|
$neverLoggedUsers = DB::select(
|
|
'select id, uuid, first_name, last_name, email, created from users where active = 1 and last_login_at is null order by created desc limit 10'
|
|
);
|
|
$neverLoggedUsers = is_array($neverLoggedUsers) ? array_map(
|
|
static fn ($row) => $row['users'] ?? $row,
|
|
$neverLoggedUsers
|
|
) : [];
|
|
|
|
$mailLogFailedCount = (int) (DB::selectValue('select count(*) from mail_log where status = ?', $mailStatusFailed) ?? 0);
|
|
$mailLogSentCount = (int) (DB::selectValue('select count(*) from mail_log where status = ?', $mailStatusSent) ?? 0);
|
|
$mailLogLastSentAt = (string) (DB::selectValue('select max(sent_at) from mail_log where status = ?', $mailStatusSent) ?? '');
|
|
$mailLogRecentFailed = DB::select(
|
|
'select id, to_email, subject, error_message, created_at from mail_log where status = ? order by created_at desc limit 5',
|
|
$mailStatusFailed
|
|
);
|
|
$mailLogRecentFailed = is_array($mailLogRecentFailed) ? array_map(
|
|
static fn ($row) => $row['mail_log'] ?? $row,
|
|
$mailLogRecentFailed
|
|
) : [];
|
|
|
|
|
|
$vars = get_defined_vars();
|
|
unset($vars['this']);
|
|
return $vars;
|
|
}
|
|
}
|