1754 lines
71 KiB
PHP
1754 lines
71 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\TenantDashboard;
|
|
|
|
use App\Filament\Pages\BaselineCompareLanding;
|
|
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
|
use App\Filament\Resources\BackupScheduleResource;
|
|
use App\Filament\Resources\BackupSetResource;
|
|
use App\Filament\Resources\EvidenceSnapshotResource;
|
|
use App\Filament\Resources\FindingExceptionResource;
|
|
use App\Filament\Resources\FindingResource;
|
|
use App\Filament\Resources\ReviewPackResource;
|
|
use App\Filament\Resources\TenantReviewResource;
|
|
use App\Models\EvidenceSnapshot;
|
|
use App\Models\Finding;
|
|
use App\Models\FindingException;
|
|
use App\Models\OperationRun;
|
|
use App\Models\ProviderConnection;
|
|
use App\Models\ReviewPack;
|
|
use App\Models\Tenant;
|
|
use App\Models\TenantReview;
|
|
use App\Models\User;
|
|
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
|
|
use App\Support\Auth\Capabilities;
|
|
use App\Support\Badges\BadgeDomain;
|
|
use App\Support\Badges\BadgeRenderer;
|
|
use App\Support\BackupHealth\BackupHealthActionTarget;
|
|
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
|
use App\Support\BackupHealth\TenantBackupHealthResolver;
|
|
use App\Support\Baselines\TenantGovernanceAggregate;
|
|
use App\Support\Baselines\TenantGovernanceAggregateResolver;
|
|
use App\Support\Links\RequiredPermissionsLinks;
|
|
use App\Support\OperationCatalog;
|
|
use App\Support\OperationRunLinks;
|
|
use App\Support\OpsUx\ActiveRuns;
|
|
use App\Support\OpsUx\OperationUxPresenter;
|
|
use App\Support\RestoreSafety\RestoreResultAttention;
|
|
use App\Support\RestoreSafety\RestoreSafetyResolver;
|
|
use App\Support\Tenants\TenantRecoveryTriagePresentation;
|
|
use App\Support\Verification\VerificationReportOverall;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Str;
|
|
|
|
final class TenantDashboardSummaryBuilder
|
|
{
|
|
public function __construct(
|
|
private readonly TenantGovernanceAggregateResolver $tenantGovernanceAggregateResolver,
|
|
private readonly TenantBackupHealthResolver $tenantBackupHealthResolver,
|
|
private readonly RestoreSafetyResolver $restoreSafetyResolver,
|
|
private readonly TenantRequiredPermissionsViewModelBuilder $tenantRequiredPermissionsViewModelBuilder,
|
|
) {}
|
|
|
|
public function build(Tenant $tenant, ?User $user = null): TenantDashboardSummary
|
|
{
|
|
$tenant->loadMissing('workspace', 'providerConnections');
|
|
|
|
$user = $user ?? auth()->user();
|
|
$primaryProviderConnection = $this->primaryProviderConnection($tenant);
|
|
|
|
$aggregate = $this->tenantGovernanceAggregateResolver->forTenant($tenant);
|
|
$backupHealth = $this->tenantBackupHealthResolver->assess($tenant);
|
|
$recoveryEvidence = $this->restoreSafetyResolver->dashboardRecoveryEvidence($tenant);
|
|
$requiredPermissions = $this->tenantRequiredPermissionsViewModelBuilder->build($tenant, [
|
|
'status' => 'missing',
|
|
]);
|
|
|
|
$latestReview = $this->latestTenantReview($tenant);
|
|
$latestReviewPack = $this->latestReviewPack($tenant);
|
|
$latestEvidenceSnapshot = $this->latestEvidenceSnapshot($tenant);
|
|
$exceptionStats = $this->exceptionStats($tenant);
|
|
$recentOperations = $this->recentOperations($tenant);
|
|
|
|
$recommendedActions = $this->recommendedActions(
|
|
tenant: $tenant,
|
|
user: $user,
|
|
aggregate: $aggregate,
|
|
backupHealth: $backupHealth,
|
|
recoveryEvidence: $recoveryEvidence,
|
|
requiredPermissions: $requiredPermissions,
|
|
latestReview: $latestReview,
|
|
latestReviewPack: $latestReviewPack,
|
|
exceptionStats: $exceptionStats,
|
|
);
|
|
|
|
return new TenantDashboardSummary(
|
|
context: [
|
|
'workspace' => (string) ($tenant->workspace?->name ?? $this->overviewText('context_workspace')),
|
|
'tenant' => (string) $tenant->name,
|
|
'provider' => $this->providerChipLabel($primaryProviderConnection),
|
|
'providerKey' => $this->providerChipKey($primaryProviderConnection),
|
|
'latestActivity' => $this->latestActivityLabel(
|
|
primaryProviderConnection: $primaryProviderConnection,
|
|
latestReview: $latestReview,
|
|
latestReviewPack: $latestReviewPack,
|
|
latestEvidenceSnapshot: $latestEvidenceSnapshot,
|
|
recentOperations: $recentOperations,
|
|
),
|
|
],
|
|
posture: $this->posture(
|
|
aggregate: $aggregate,
|
|
backupHealth: $backupHealth,
|
|
recoveryEvidence: $recoveryEvidence,
|
|
requiredPermissions: $requiredPermissions,
|
|
recommendedActions: $recommendedActions,
|
|
),
|
|
kpis: $this->kpis($tenant, $user, $aggregate, $requiredPermissions),
|
|
recommendedActions: $recommendedActions,
|
|
governanceStatus: $this->governanceStatus(
|
|
tenant: $tenant,
|
|
user: $user,
|
|
aggregate: $aggregate,
|
|
backupHealth: $backupHealth,
|
|
requiredPermissions: $requiredPermissions,
|
|
latestReview: $latestReview,
|
|
latestEvidenceSnapshot: $latestEvidenceSnapshot,
|
|
),
|
|
readinessCards: $this->readinessCards(
|
|
tenant: $tenant,
|
|
user: $user,
|
|
primaryProviderConnection: $primaryProviderConnection,
|
|
requiredPermissions: $requiredPermissions,
|
|
latestReview: $latestReview,
|
|
latestReviewPack: $latestReviewPack,
|
|
latestEvidenceSnapshot: $latestEvidenceSnapshot,
|
|
exceptionStats: $exceptionStats,
|
|
),
|
|
recentOperations: $this->recentOperationCards($tenant, $recentOperations),
|
|
pollingInterval: ActiveRuns::pollingIntervalForTenant($tenant),
|
|
);
|
|
}
|
|
|
|
private function primaryProviderConnection(Tenant $tenant): ?ProviderConnection
|
|
{
|
|
$connections = $tenant->providerConnections;
|
|
|
|
return $connections->first(static fn (ProviderConnection $connection): bool => $connection->is_enabled && $connection->is_default)
|
|
?? $connections->first(static fn (ProviderConnection $connection): bool => $connection->is_enabled)
|
|
?? $connections->first();
|
|
}
|
|
|
|
private function providerChipLabel(?ProviderConnection $connection): ?string
|
|
{
|
|
if (! $connection instanceof ProviderConnection || blank($connection->provider)) {
|
|
return null;
|
|
}
|
|
|
|
return Str::headline((string) $connection->provider);
|
|
}
|
|
|
|
private function providerChipKey(?ProviderConnection $connection): ?string
|
|
{
|
|
if (! $connection instanceof ProviderConnection || blank($connection->provider)) {
|
|
return null;
|
|
}
|
|
|
|
return Str::lower((string) $connection->provider);
|
|
}
|
|
|
|
/**
|
|
* @param list<OperationRun> $recentOperations
|
|
*/
|
|
private function latestActivityLabel(
|
|
?ProviderConnection $primaryProviderConnection,
|
|
?TenantReview $latestReview,
|
|
?ReviewPack $latestReviewPack,
|
|
?EvidenceSnapshot $latestEvidenceSnapshot,
|
|
array $recentOperations,
|
|
): ?string {
|
|
$timestamp = $this->latestActivityTimestamp(
|
|
primaryProviderConnection: $primaryProviderConnection,
|
|
latestReview: $latestReview,
|
|
latestReviewPack: $latestReviewPack,
|
|
latestEvidenceSnapshot: $latestEvidenceSnapshot,
|
|
recentOperations: $recentOperations,
|
|
);
|
|
|
|
return $timestamp?->diffForHumans();
|
|
}
|
|
|
|
/**
|
|
* @param list<OperationRun> $recentOperations
|
|
*/
|
|
private function latestActivityTimestamp(
|
|
?ProviderConnection $primaryProviderConnection,
|
|
?TenantReview $latestReview,
|
|
?ReviewPack $latestReviewPack,
|
|
?EvidenceSnapshot $latestEvidenceSnapshot,
|
|
array $recentOperations,
|
|
): ?Carbon {
|
|
$candidates = [];
|
|
|
|
foreach ($recentOperations as $operation) {
|
|
$timestamp = $operation->completed_at ?? $operation->created_at;
|
|
|
|
if ($timestamp instanceof Carbon) {
|
|
$candidates[] = $timestamp;
|
|
}
|
|
}
|
|
|
|
foreach ([
|
|
$primaryProviderConnection?->last_health_check_at,
|
|
$primaryProviderConnection?->consent_last_checked_at,
|
|
$latestReview?->published_at ?? $latestReview?->generated_at,
|
|
$latestReviewPack?->generated_at,
|
|
$latestEvidenceSnapshot?->generated_at,
|
|
] as $timestamp) {
|
|
if ($timestamp instanceof Carbon) {
|
|
$candidates[] = $timestamp;
|
|
}
|
|
}
|
|
|
|
if ($candidates === []) {
|
|
return null;
|
|
}
|
|
|
|
usort($candidates, static fn (Carbon $left, Carbon $right): int => $right->getTimestamp() <=> $left->getTimestamp());
|
|
|
|
return $candidates[0];
|
|
}
|
|
|
|
private function latestTenantReview(Tenant $tenant): ?TenantReview
|
|
{
|
|
return TenantReview::query()
|
|
->with(['tenant', 'evidenceSnapshot', 'currentExportReviewPack'])
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->latest('generated_at')
|
|
->latest('id')
|
|
->first();
|
|
}
|
|
|
|
private function latestReviewPack(Tenant $tenant): ?ReviewPack
|
|
{
|
|
return ReviewPack::query()
|
|
->with(['tenant', 'tenantReview'])
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->latest('generated_at')
|
|
->latest('id')
|
|
->first();
|
|
}
|
|
|
|
private function latestEvidenceSnapshot(Tenant $tenant): ?EvidenceSnapshot
|
|
{
|
|
return EvidenceSnapshot::query()
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->latest('generated_at')
|
|
->latest('id')
|
|
->first();
|
|
}
|
|
|
|
/**
|
|
* @return array{active:int,expiring:int,expired:int,pending:int,total:int}
|
|
*/
|
|
private function exceptionStats(Tenant $tenant): array
|
|
{
|
|
$counts = FindingException::query()
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->where('workspace_id', (int) $tenant->workspace_id)
|
|
->selectRaw('count(*) as total')
|
|
->selectRaw("count(*) filter (where status = 'active') as active")
|
|
->selectRaw("count(*) filter (where status = 'expiring') as expiring")
|
|
->selectRaw("count(*) filter (where status = 'expired') as expired")
|
|
->selectRaw("count(*) filter (where status = 'pending') as pending")
|
|
->first();
|
|
|
|
return [
|
|
'active' => (int) ($counts?->active ?? 0),
|
|
'expiring' => (int) ($counts?->expiring ?? 0),
|
|
'expired' => (int) ($counts?->expired ?? 0),
|
|
'pending' => (int) ($counts?->pending ?? 0),
|
|
'total' => (int) ($counts?->total ?? 0),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return list<OperationRun>
|
|
*/
|
|
private function recentOperations(Tenant $tenant): array
|
|
{
|
|
return OperationRun::query()
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->latest('created_at')
|
|
->latest('id')
|
|
->limit(4)
|
|
->get()
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $requiredPermissions
|
|
* @param list<array<string, mixed>> $recommendedActions
|
|
* @return array{status:string,tone:string,headline:string,summary:string}
|
|
*/
|
|
private function posture(
|
|
TenantGovernanceAggregate $aggregate,
|
|
TenantBackupHealthAssessment $backupHealth,
|
|
array $recoveryEvidence,
|
|
array $requiredPermissions,
|
|
array $recommendedActions,
|
|
): array {
|
|
$counts = is_array($requiredPermissions['overview']['counts'] ?? null)
|
|
? $requiredPermissions['overview']['counts']
|
|
: [];
|
|
$missingApplicationPermissions = (int) ($counts['missing_application'] ?? 0);
|
|
|
|
if ($missingApplicationPermissions > 0) {
|
|
return [
|
|
'status' => $this->overviewText('status_blocked'),
|
|
'tone' => 'danger',
|
|
'headline' => $this->overviewText('posture_blocked_headline'),
|
|
'summary' => $this->overviewText('posture_blocked_summary'),
|
|
];
|
|
}
|
|
|
|
if ($recommendedActions !== [] || $aggregate->stateFamily !== 'positive' || $backupHealth->posture !== TenantBackupHealthAssessment::POSTURE_HEALTHY || TenantRecoveryTriagePresentation::recoveryEvidenceState($recoveryEvidence) !== TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_NO_RECENT_ISSUES_VISIBLE) {
|
|
$topAction = $recommendedActions[0] ?? null;
|
|
|
|
return [
|
|
'status' => $this->overviewText('status_action_needed'),
|
|
'tone' => (string) ($topAction['tone'] ?? 'warning'),
|
|
'headline' => is_string($topAction['reason'] ?? null)
|
|
? (string) $topAction['reason']
|
|
: $aggregate->headline,
|
|
'summary' => is_string($topAction['impact'] ?? null)
|
|
? (string) $topAction['impact']
|
|
: ($aggregate->supportingMessage ?? $this->overviewText('posture_action_needed_fallback_summary')),
|
|
];
|
|
}
|
|
|
|
return [
|
|
'status' => $this->overviewText('status_calm'),
|
|
'tone' => 'success',
|
|
'headline' => $this->overviewText('posture_calm_headline'),
|
|
'summary' => $this->overviewText('posture_calm_summary'),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $requiredPermissions
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private function kpis(Tenant $tenant, ?User $user, TenantGovernanceAggregate $aggregate, array $requiredPermissions): array
|
|
{
|
|
$counts = is_array($requiredPermissions['overview']['counts'] ?? null)
|
|
? $requiredPermissions['overview']['counts']
|
|
: [];
|
|
$missingApplicationPermissions = (int) ($counts['missing_application'] ?? 0);
|
|
$missingDelegatedPermissions = (int) ($counts['missing_delegated'] ?? 0);
|
|
$highSeverityChart = $this->highSeverityFindingsChart($tenant);
|
|
$operationsFollowUpChart = $this->operationsFollowUpChart($tenant);
|
|
|
|
$operationsNeedingFollowUp = (int) OperationRun::query()
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->where(function ($query): void {
|
|
$query->terminalFollowUp()->orWhere(fn ($inner) => $inner->activeStaleAttention());
|
|
})
|
|
->count();
|
|
|
|
return [
|
|
$this->metricCard(
|
|
key: 'high_severity_findings',
|
|
label: $this->overviewText('kpi_high_severity_label'),
|
|
value: $aggregate->highSeverityActiveFindingsCount,
|
|
description: $this->highSeverityKpiDescription($aggregate->highSeverityActiveFindingsCount, $highSeverityChart),
|
|
tone: $aggregate->highSeverityActiveFindingsCount > 0 ? 'danger' : 'gray',
|
|
icon: $this->trendDirectionIcon($aggregate->highSeverityActiveFindingsCount > 0),
|
|
chart: $highSeverityChart,
|
|
action: $this->tenantFindingsAction($tenant, $user, $this->overviewText('action_review_findings'), [
|
|
'tab' => 'needs_action',
|
|
'high_severity' => 1,
|
|
]),
|
|
),
|
|
$this->metricCard(
|
|
key: 'overdue_findings',
|
|
label: $this->overviewText('kpi_overdue_label'),
|
|
value: $aggregate->overdueOpenFindingsCount,
|
|
description: $this->overdueKpiDescription($aggregate->overdueOpenFindingsCount),
|
|
tone: $aggregate->overdueOpenFindingsCount > 0 ? 'warning' : 'gray',
|
|
icon: $this->trendDirectionIcon($aggregate->overdueOpenFindingsCount > 0),
|
|
action: $this->tenantFindingsAction($tenant, $user, $this->overviewText('action_open_overdue_findings'), [
|
|
'tab' => 'overdue',
|
|
]),
|
|
),
|
|
$this->metricCard(
|
|
key: 'missing_permissions',
|
|
label: $this->overviewText('kpi_missing_permissions_label'),
|
|
value: $missingApplicationPermissions + $missingDelegatedPermissions,
|
|
description: $this->missingPermissionsKpiDescription($missingApplicationPermissions, $missingDelegatedPermissions),
|
|
tone: $missingApplicationPermissions > 0
|
|
? 'danger'
|
|
: ($missingDelegatedPermissions > 0 ? 'warning' : 'gray'),
|
|
icon: $this->trendDirectionIcon(($missingApplicationPermissions + $missingDelegatedPermissions) > 0),
|
|
action: $this->requiredPermissionsAction($tenant, $user, $this->overviewText('action_open_required_permissions')),
|
|
),
|
|
$this->metricCard(
|
|
key: 'active_operations',
|
|
label: $this->overviewText('kpi_active_operations_label'),
|
|
value: $operationsNeedingFollowUp,
|
|
description: $this->activeOperationsKpiDescription($operationsNeedingFollowUp, $operationsFollowUpChart),
|
|
tone: $operationsNeedingFollowUp > 0 ? 'warning' : 'gray',
|
|
icon: $this->trendDirectionIcon($operationsNeedingFollowUp > 0),
|
|
chart: $operationsFollowUpChart,
|
|
action: $this->operationsAction(
|
|
tenant: $tenant,
|
|
user: $user,
|
|
label: $this->overviewText('action_view_all_operations'),
|
|
activeTab: $operationsNeedingFollowUp > 0 ? OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP : 'active',
|
|
problemClass: $operationsNeedingFollowUp > 0 ? OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP : null,
|
|
),
|
|
),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param list<int>|null $chart
|
|
*/
|
|
private function highSeverityKpiDescription(int $count, ?array $chart): string
|
|
{
|
|
if ($count <= 0) {
|
|
return $this->overviewText('kpi_high_severity_tendency_none');
|
|
}
|
|
|
|
$windowCount = $chart === null ? 0 : array_sum($chart);
|
|
|
|
if ($windowCount > 0) {
|
|
return $this->overviewText('kpi_high_severity_tendency_window', [
|
|
'count' => $count,
|
|
'window' => $windowCount,
|
|
]);
|
|
}
|
|
|
|
return $this->overviewText('kpi_high_severity_tendency', ['count' => $count]);
|
|
}
|
|
|
|
private function overdueKpiDescription(int $count): string
|
|
{
|
|
if ($count <= 0) {
|
|
return $this->overviewText('kpi_overdue_tendency_none');
|
|
}
|
|
|
|
return $this->overviewText('kpi_overdue_tendency', ['count' => $count]);
|
|
}
|
|
|
|
private function missingPermissionsKpiDescription(int $missingApplicationPermissions, int $missingDelegatedPermissions): string
|
|
{
|
|
$totalMissingPermissions = $missingApplicationPermissions + $missingDelegatedPermissions;
|
|
|
|
if ($totalMissingPermissions <= 0) {
|
|
return $this->overviewText('kpi_missing_permissions_tendency_none');
|
|
}
|
|
|
|
if ($missingApplicationPermissions > 0 && $missingDelegatedPermissions > 0) {
|
|
return $this->overviewText('kpi_missing_permissions_tendency_split', [
|
|
'app' => $missingApplicationPermissions,
|
|
'delegated' => $missingDelegatedPermissions,
|
|
]);
|
|
}
|
|
|
|
if ($missingApplicationPermissions > 0) {
|
|
return $this->overviewText('kpi_missing_permissions_tendency_app_only', [
|
|
'count' => $missingApplicationPermissions,
|
|
]);
|
|
}
|
|
|
|
return $this->overviewText('kpi_missing_permissions_tendency_delegated_only', [
|
|
'count' => $missingDelegatedPermissions,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param list<int>|null $chart
|
|
*/
|
|
private function activeOperationsKpiDescription(int $count, ?array $chart): string
|
|
{
|
|
if ($count <= 0) {
|
|
return $this->overviewText('kpi_active_operations_tendency_none');
|
|
}
|
|
|
|
$windowCount = $chart === null ? 0 : array_sum($chart);
|
|
|
|
if ($windowCount > 0) {
|
|
return $this->overviewText('kpi_active_operations_tendency_window', [
|
|
'count' => $count,
|
|
'window' => $windowCount,
|
|
]);
|
|
}
|
|
|
|
return $this->overviewText('kpi_active_operations_tendency', ['count' => $count]);
|
|
}
|
|
|
|
private function trendDirectionIcon(bool $hasAttention): string
|
|
{
|
|
return $hasAttention
|
|
? 'heroicon-m-arrow-trending-up'
|
|
: 'heroicon-m-arrow-trending-down';
|
|
}
|
|
|
|
/**
|
|
* @return list<int>|null
|
|
*/
|
|
private function highSeverityFindingsChart(Tenant $tenant): ?array
|
|
{
|
|
$window = $this->sevenDayWindow();
|
|
$byDay = Finding::query()
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->whereIn('severity', Finding::highSeverityValues())
|
|
->where(function (Builder $query) use ($window): void {
|
|
$query
|
|
->whereBetween('first_seen_at', [$window['start'], $window['end']])
|
|
->orWhere(function (Builder $fallbackQuery) use ($window): void {
|
|
$fallbackQuery
|
|
->whereNull('first_seen_at')
|
|
->whereBetween('created_at', [$window['start'], $window['end']]);
|
|
});
|
|
})
|
|
->selectRaw('date(coalesce(first_seen_at, created_at)) as chart_date, count(*) as aggregate')
|
|
->groupByRaw('date(coalesce(first_seen_at, created_at))')
|
|
->pluck('aggregate', 'chart_date')
|
|
->all();
|
|
|
|
return $this->normalizeSevenDayChart($byDay, $window['start']);
|
|
}
|
|
|
|
/**
|
|
* @return list<int>|null
|
|
*/
|
|
private function operationsFollowUpChart(Tenant $tenant): ?array
|
|
{
|
|
$window = $this->sevenDayWindow();
|
|
$byDay = OperationRun::query()
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->dashboardNeedsFollowUp()
|
|
->where(function (Builder $query) use ($window): void {
|
|
$query
|
|
->whereBetween('completed_at', [$window['start'], $window['end']])
|
|
->orWhere(function (Builder $activeQuery) use ($window): void {
|
|
$activeQuery
|
|
->whereNull('completed_at')
|
|
->whereBetween('created_at', [$window['start'], $window['end']]);
|
|
});
|
|
})
|
|
->selectRaw('date(coalesce(completed_at, created_at)) as chart_date, count(*) as aggregate')
|
|
->groupByRaw('date(coalesce(completed_at, created_at))')
|
|
->pluck('aggregate', 'chart_date')
|
|
->all();
|
|
|
|
return $this->normalizeSevenDayChart($byDay, $window['start']);
|
|
}
|
|
|
|
/**
|
|
* @return array{start: Carbon, end: Carbon}
|
|
*/
|
|
private function sevenDayWindow(): array
|
|
{
|
|
$end = now()->endOfDay();
|
|
|
|
return [
|
|
'start' => $end->copy()->startOfDay()->subDays(6),
|
|
'end' => $end,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, int|string> $byDay
|
|
* @return list<int>|null
|
|
*/
|
|
private function normalizeSevenDayChart(array $byDay, Carbon $windowStart): ?array
|
|
{
|
|
$series = [];
|
|
|
|
for ($day = $windowStart->copy(); $day->lte(now()); $day->addDay()) {
|
|
$series[] = (int) ($byDay[$day->toDateString()] ?? 0);
|
|
}
|
|
|
|
return array_sum($series) > 0 ? $series : null;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $requiredPermissions
|
|
* @param array{active:int,expiring:int,expired:int,pending:int,total:int} $exceptionStats
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private function recommendedActions(
|
|
Tenant $tenant,
|
|
?User $user,
|
|
TenantGovernanceAggregate $aggregate,
|
|
TenantBackupHealthAssessment $backupHealth,
|
|
array $recoveryEvidence,
|
|
array $requiredPermissions,
|
|
?TenantReview $latestReview,
|
|
?ReviewPack $latestReviewPack,
|
|
array $exceptionStats,
|
|
): array {
|
|
$counts = is_array($requiredPermissions['overview']['counts'] ?? null)
|
|
? $requiredPermissions['overview']['counts']
|
|
: [];
|
|
$overview = is_array($requiredPermissions['overview'] ?? null)
|
|
? $requiredPermissions['overview']
|
|
: [];
|
|
|
|
$candidates = [];
|
|
|
|
$missingApplicationPermissions = (int) ($counts['missing_application'] ?? 0);
|
|
$missingDelegatedPermissions = (int) ($counts['missing_delegated'] ?? 0);
|
|
|
|
if ($missingApplicationPermissions > 0) {
|
|
$candidates[] = $this->actionCandidate(
|
|
priority: 10,
|
|
key: 'required_permissions',
|
|
title: $this->overviewText('action_open_required_permissions'),
|
|
reason: $this->overviewText('reason_missing_application_permissions', ['count' => $missingApplicationPermissions]),
|
|
impact: $this->overviewText('impact_missing_application_permissions'),
|
|
tone: 'danger',
|
|
action: $this->requiredPermissionsAction($tenant, $user, $this->overviewText('action_open_required_permissions')),
|
|
);
|
|
} elseif ($missingDelegatedPermissions > 0) {
|
|
$candidates[] = $this->actionCandidate(
|
|
priority: 20,
|
|
key: 'delegated_permissions',
|
|
title: $this->overviewText('action_open_required_permissions'),
|
|
reason: $this->overviewText('reason_missing_delegated_permissions', ['count' => $missingDelegatedPermissions]),
|
|
impact: $this->overviewText('impact_missing_delegated_permissions'),
|
|
tone: 'warning',
|
|
action: $this->requiredPermissionsAction($tenant, $user, $this->overviewText('action_open_required_permissions')),
|
|
);
|
|
}
|
|
|
|
if ($aggregate->highSeverityActiveFindingsCount > 0) {
|
|
$candidates[] = $this->actionCandidate(
|
|
priority: 30,
|
|
key: 'high_severity_findings',
|
|
title: $this->overviewText('action_review_findings'),
|
|
reason: $this->overviewText('reason_high_severity_findings', ['count' => $aggregate->highSeverityActiveFindingsCount]),
|
|
impact: $this->overviewText('impact_high_severity_findings'),
|
|
tone: 'danger',
|
|
action: $this->tenantFindingsAction($tenant, $user, $this->overviewText('action_review_findings'), [
|
|
'tab' => 'needs_action',
|
|
'high_severity' => 1,
|
|
]),
|
|
);
|
|
}
|
|
|
|
if ($aggregate->overdueOpenFindingsCount > 0) {
|
|
$candidates[] = $this->actionCandidate(
|
|
priority: 40,
|
|
key: 'overdue_findings',
|
|
title: $this->overviewText('action_review_findings'),
|
|
reason: $this->overviewText('reason_overdue_findings', ['count' => $aggregate->overdueOpenFindingsCount]),
|
|
impact: $this->overviewText('impact_overdue_findings'),
|
|
tone: 'warning',
|
|
action: $this->tenantFindingsAction($tenant, $user, $this->overviewText('action_open_overdue_findings'), [
|
|
'tab' => 'overdue',
|
|
]),
|
|
);
|
|
}
|
|
|
|
$exceptionNeedsAction = $exceptionStats['pending'] + $exceptionStats['expiring'] + $exceptionStats['expired'];
|
|
|
|
if ($exceptionNeedsAction > 0 || $aggregate->lapsedGovernanceCount > 0 || $aggregate->expiringGovernanceCount > 0) {
|
|
$candidates[] = $this->actionCandidate(
|
|
priority: 50,
|
|
key: 'risk_exceptions',
|
|
title: $this->overviewText('action_review_risks'),
|
|
reason: $this->overviewText('reason_risk_exceptions', ['count' => max($exceptionNeedsAction, $aggregate->lapsedGovernanceCount + $aggregate->expiringGovernanceCount)]),
|
|
impact: $this->overviewText('impact_risk_exceptions'),
|
|
tone: $aggregate->lapsedGovernanceCount > 0 || $exceptionStats['expired'] > 0 ? 'danger' : 'warning',
|
|
action: $this->riskExceptionsAction($tenant, $user, $this->overviewText('action_review_risks')),
|
|
);
|
|
}
|
|
|
|
if ($backupHealth->posture !== TenantBackupHealthAssessment::POSTURE_HEALTHY || TenantRecoveryTriagePresentation::recoveryEvidenceState($recoveryEvidence) !== TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_NO_RECENT_ISSUES_VISIBLE) {
|
|
$candidates[] = $this->actionCandidate(
|
|
priority: 60,
|
|
key: 'recovery_posture',
|
|
title: $this->overviewText('action_review_recovery_posture'),
|
|
reason: TenantRecoveryTriagePresentation::backupPostureDescription($backupHealth) ?? $backupHealth->headline,
|
|
impact: $this->overviewText('impact_recovery_posture'),
|
|
tone: $backupHealth->tone(),
|
|
action: $this->backupHealthAction($tenant, $user, $this->overviewText('action_open_backup_posture'), $backupHealth),
|
|
);
|
|
}
|
|
|
|
$terminalFollowUpRuns = (int) OperationRun::query()
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->terminalFollowUp()
|
|
->count();
|
|
|
|
if ($terminalFollowUpRuns > 0) {
|
|
$candidates[] = $this->actionCandidate(
|
|
priority: 70,
|
|
key: 'terminal_operations',
|
|
title: $this->overviewText('action_view_all_operations'),
|
|
reason: $this->overviewText('reason_terminal_operations', ['count' => $terminalFollowUpRuns]),
|
|
impact: $this->overviewText('impact_terminal_operations'),
|
|
tone: 'danger',
|
|
action: $this->operationsAction(
|
|
tenant: $tenant,
|
|
user: $user,
|
|
label: $this->overviewText('action_view_all_operations'),
|
|
activeTab: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
|
problemClass: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
|
),
|
|
);
|
|
}
|
|
|
|
if ($latestReview instanceof TenantReview && $latestReviewPack?->status !== 'ready') {
|
|
$candidates[] = $this->actionCandidate(
|
|
priority: 80,
|
|
key: 'continue_review',
|
|
title: $this->overviewText('action_continue_review'),
|
|
reason: $this->overviewText('reason_continue_review'),
|
|
impact: $this->overviewText('impact_continue_review'),
|
|
tone: 'info',
|
|
action: $this->continueReviewAction($tenant, $user, $latestReview),
|
|
);
|
|
}
|
|
|
|
return collect($candidates)
|
|
->sortBy('priority')
|
|
->take(3)
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $requiredPermissions
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private function governanceStatus(
|
|
Tenant $tenant,
|
|
?User $user,
|
|
TenantGovernanceAggregate $aggregate,
|
|
TenantBackupHealthAssessment $backupHealth,
|
|
array $requiredPermissions,
|
|
?TenantReview $latestReview,
|
|
?EvidenceSnapshot $latestEvidenceSnapshot,
|
|
): array {
|
|
$overview = is_array($requiredPermissions['overview'] ?? null)
|
|
? $requiredPermissions['overview']
|
|
: [];
|
|
$counts = is_array($overview['counts'] ?? null)
|
|
? $overview['counts']
|
|
: [];
|
|
$freshness = is_array($overview['freshness'] ?? null)
|
|
? $overview['freshness']
|
|
: [];
|
|
|
|
return [
|
|
[
|
|
'key' => 'baseline_compare',
|
|
'label' => $this->overviewText('governance_baseline_compare_label'),
|
|
'icon' => $this->governanceStatusIcon('baseline_compare'),
|
|
'value' => $aggregate->headline,
|
|
'tone' => $aggregate->tone,
|
|
'description' => $aggregate->supportingMessage ?? $this->overviewText('governance_baseline_compare_description'),
|
|
...$this->baselineCompareAction($tenant, $user, $this->overviewText('action_open_baseline_compare')),
|
|
],
|
|
[
|
|
'key' => 'evidence_coverage',
|
|
'label' => $this->overviewText('governance_evidence_coverage_label'),
|
|
'icon' => $this->governanceStatusIcon('evidence_coverage'),
|
|
'value' => $this->evidenceCoverageValue($latestEvidenceSnapshot),
|
|
'tone' => $this->evidenceCoverageTone($latestEvidenceSnapshot),
|
|
'description' => $this->evidenceCoverageDescription($latestEvidenceSnapshot),
|
|
...$this->evidenceAction($tenant, $user, $this->overviewText('action_open_evidence'), $latestEvidenceSnapshot),
|
|
],
|
|
[
|
|
'key' => 'review_freshness',
|
|
'label' => $this->overviewText('governance_review_freshness_label'),
|
|
'icon' => $this->governanceStatusIcon('review_freshness'),
|
|
'value' => $this->reviewValue($latestReview),
|
|
'tone' => $this->reviewTone($latestReview),
|
|
'description' => $this->reviewDescription($latestReview),
|
|
...$this->tenantReviewAction($tenant, $user, $this->reviewSurfaceActionLabel($tenant, $user, $latestReview), $latestReview),
|
|
],
|
|
[
|
|
'key' => 'provider_permissions',
|
|
'label' => $this->overviewText('governance_provider_permissions_label'),
|
|
'icon' => $this->governanceStatusIcon('provider_permissions'),
|
|
'value' => $this->providerPermissionsValue($overview),
|
|
'tone' => $this->providerPermissionsTone($overview),
|
|
'description' => $this->providerPermissionsDescription($counts, $freshness),
|
|
...$this->requiredPermissionsAction($tenant, $user, $this->overviewText('action_open_required_permissions')),
|
|
],
|
|
[
|
|
'key' => 'backup_posture',
|
|
'label' => $this->overviewText('governance_backup_posture_label'),
|
|
'icon' => $this->governanceStatusIcon('backup_posture'),
|
|
'value' => TenantRecoveryTriagePresentation::backupPostureLabel($backupHealth),
|
|
'tone' => $backupHealth->tone(),
|
|
'description' => TenantRecoveryTriagePresentation::backupPostureDescription($backupHealth) ?? $this->overviewText('governance_backup_posture_unavailable_description'),
|
|
...$this->backupHealthAction($tenant, $user, $this->overviewText('action_open_backup_posture'), $backupHealth),
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $requiredPermissions
|
|
* @param array{active:int,expiring:int,expired:int,pending:int,total:int} $exceptionStats
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private function readinessCards(
|
|
Tenant $tenant,
|
|
?User $user,
|
|
?ProviderConnection $primaryProviderConnection,
|
|
array $requiredPermissions,
|
|
?TenantReview $latestReview,
|
|
?ReviewPack $latestReviewPack,
|
|
?EvidenceSnapshot $latestEvidenceSnapshot,
|
|
array $exceptionStats,
|
|
): array {
|
|
return [
|
|
$this->currentReviewCard($tenant, $user, $latestReview),
|
|
$this->riskExceptionsCard($tenant, $user, $exceptionStats),
|
|
$this->providerHealthCard($tenant, $user, $primaryProviderConnection, $requiredPermissions),
|
|
$this->customerSafeOutputCard($tenant, $user, $latestReviewPack, $latestEvidenceSnapshot),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function currentReviewCard(Tenant $tenant, ?User $user, ?TenantReview $latestReview): array
|
|
{
|
|
$timestamp = $latestReview?->published_at ?? $latestReview?->generated_at ?? $latestReview?->updated_at;
|
|
|
|
return [
|
|
'key' => 'current_review',
|
|
'title' => $this->overviewText('readiness_current_review_title'),
|
|
'status' => $latestReview instanceof TenantReview
|
|
? $this->reviewValue($latestReview)
|
|
: $this->overviewText('readiness_current_review_empty_status'),
|
|
'tone' => $latestReview instanceof TenantReview ? $this->reviewTone($latestReview) : 'gray',
|
|
'body' => $latestReview instanceof TenantReview
|
|
? $this->reviewDescription($latestReview)
|
|
: $this->overviewText('readiness_current_review_empty_description'),
|
|
'progress' => $this->reviewProgress($latestReview),
|
|
'meta' => $this->cardMeta(
|
|
$this->metaItem(
|
|
$this->overviewText('readiness_current_review_updated_label'),
|
|
$this->relativeTime($timestamp),
|
|
),
|
|
),
|
|
...$this->tenantReviewAction($tenant, $user, $this->reviewSurfaceActionLabel($tenant, $user, $latestReview), $latestReview),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array{active:int,expiring:int,expired:int,pending:int,total:int} $exceptionStats
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function riskExceptionsCard(Tenant $tenant, ?User $user, array $exceptionStats): array
|
|
{
|
|
return [
|
|
'key' => 'risk_exceptions',
|
|
'title' => $this->overviewText('readiness_risk_exceptions_title'),
|
|
'status' => $this->riskExceptionValue($exceptionStats),
|
|
'tone' => $this->riskExceptionTone($exceptionStats),
|
|
'body' => $this->riskExceptionDescription($exceptionStats),
|
|
'meta' => $this->cardMeta(
|
|
$this->metaItem($this->overviewText('readiness_risk_exceptions_active_label'), (string) $exceptionStats['active']),
|
|
$this->metaItem($this->overviewText('readiness_risk_exceptions_expiring_label'), (string) $exceptionStats['expiring']),
|
|
$this->metaItem($this->overviewText('readiness_risk_exceptions_pending_label'), (string) $exceptionStats['pending']),
|
|
),
|
|
...$this->riskExceptionsAction($tenant, $user, $this->overviewText('action_review_risks')),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $requiredPermissions
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function providerHealthCard(
|
|
Tenant $tenant,
|
|
?User $user,
|
|
?ProviderConnection $primaryProviderConnection,
|
|
array $requiredPermissions,
|
|
): array {
|
|
$overview = is_array($requiredPermissions['overview'] ?? null) ? $requiredPermissions['overview'] : [];
|
|
$counts = is_array($overview['counts'] ?? null) ? $overview['counts'] : [];
|
|
$freshness = is_array($overview['freshness'] ?? null) ? $overview['freshness'] : [];
|
|
|
|
return [
|
|
'key' => 'provider_health',
|
|
'title' => $this->overviewText('readiness_provider_health_title'),
|
|
'headline' => $this->providerHealthHeadline($primaryProviderConnection),
|
|
'status' => $this->providerHealthStatus($primaryProviderConnection),
|
|
'tone' => $this->providerHealthTone($primaryProviderConnection),
|
|
'body' => $this->providerHealthDescription($primaryProviderConnection, $counts, $freshness),
|
|
'meta' => $this->cardMeta(
|
|
$this->metaItem(
|
|
$this->overviewText('readiness_provider_health_permissions_label'),
|
|
(string) ((int) ($counts['missing_application'] ?? 0) + (int) ($counts['missing_delegated'] ?? 0)),
|
|
),
|
|
$this->metaItem(
|
|
$this->overviewText('readiness_provider_health_last_check_label'),
|
|
$this->relativeTime($primaryProviderConnection?->last_health_check_at),
|
|
),
|
|
$this->metaItem(
|
|
$this->overviewText('readiness_provider_health_snapshot_label'),
|
|
$this->relativeTimeFromString($freshness['last_refreshed_at'] ?? null),
|
|
),
|
|
),
|
|
...$this->requiredPermissionsAction($tenant, $user, $this->overviewText('action_open_required_permissions')),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function customerSafeOutputCard(
|
|
Tenant $tenant,
|
|
?User $user,
|
|
?ReviewPack $latestReviewPack,
|
|
?EvidenceSnapshot $latestEvidenceSnapshot,
|
|
): array {
|
|
return [
|
|
'key' => 'customer_safe_output',
|
|
'title' => $this->overviewText('readiness_customer_safe_output_title'),
|
|
'status' => $this->customerSafeOutputStatus($latestReviewPack, $latestEvidenceSnapshot),
|
|
'tone' => $this->customerSafeOutputTone($latestReviewPack, $latestEvidenceSnapshot),
|
|
'body' => $this->customerSafeOutputDescription($latestReviewPack, $latestEvidenceSnapshot),
|
|
'meta' => $this->cardMeta(
|
|
$this->metaItem(
|
|
$this->overviewText('readiness_customer_safe_output_evidence_label'),
|
|
$this->relativeTime($latestEvidenceSnapshot?->generated_at) ?? $this->overviewText('status_unavailable'),
|
|
),
|
|
$this->metaItem(
|
|
$this->overviewText('readiness_customer_safe_output_review_pack_label'),
|
|
$this->relativeTime($latestReviewPack?->generated_at) ?? $this->overviewText('status_unavailable'),
|
|
),
|
|
),
|
|
...$this->customerWorkspaceAction($tenant, $user, $latestReviewPack),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param list<OperationRun> $recentOperations
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private function recentOperationCards(Tenant $tenant, array $recentOperations): array
|
|
{
|
|
return collect($recentOperations)
|
|
->map(function (OperationRun $operation) use ($tenant): array {
|
|
$statusSpec = BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
|
|
'status' => (string) $operation->status,
|
|
'freshness_state' => $operation->freshnessState()->value,
|
|
]);
|
|
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
|
|
'outcome' => (string) $operation->outcome,
|
|
'status' => (string) $operation->status,
|
|
'freshness_state' => $operation->freshnessState()->value,
|
|
]);
|
|
|
|
return [
|
|
'id' => (int) $operation->getKey(),
|
|
'identifier' => OperationRunLinks::identifier($operation),
|
|
'type' => OperationCatalog::label((string) $operation->type),
|
|
'icon' => $this->recentOperationIcon((string) $operation->type),
|
|
'statusLabel' => $statusSpec->label,
|
|
'statusTone' => $statusSpec->color,
|
|
'outcomeLabel' => $outcomeSpec->label,
|
|
'outcomeTone' => $outcomeSpec->color,
|
|
'summary' => OperationUxPresenter::lifecycleAttentionSummary($operation)
|
|
?? OperationUxPresenter::surfaceGuidance($operation)
|
|
?? $this->overviewText('recent_operation_fallback_summary'),
|
|
'url' => OperationRunLinks::view($operation, $tenant),
|
|
'createdAt' => $operation->created_at?->diffForHumans(),
|
|
];
|
|
})
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
private function governanceStatusIcon(string $key): string
|
|
{
|
|
return match ($key) {
|
|
'baseline_compare' => 'heroicon-m-arrows-right-left',
|
|
'evidence_coverage' => 'heroicon-m-document-check',
|
|
'review_freshness' => 'heroicon-m-clipboard-document-check',
|
|
'provider_permissions' => 'heroicon-m-key',
|
|
'backup_posture' => 'heroicon-m-archive-box',
|
|
default => 'heroicon-m-arrow-path-rounded-square',
|
|
};
|
|
}
|
|
|
|
private function recentOperationIcon(string $operationType): string
|
|
{
|
|
return match (OperationCatalog::canonicalCode($operationType)) {
|
|
'inventory.sync' => 'heroicon-m-arrow-path',
|
|
'tenant.review_pack.generate' => 'heroicon-m-document-arrow-down',
|
|
'tenant.review.compose' => 'heroicon-m-document-text',
|
|
'tenant.evidence.snapshot.generate' => 'heroicon-m-document-duplicate',
|
|
'baseline.compare' => 'heroicon-m-arrows-right-left',
|
|
'provider.connection.check', 'rbac.health_check' => 'heroicon-m-shield-check',
|
|
'permission.posture.check' => 'heroicon-m-key',
|
|
default => 'heroicon-m-arrow-path-rounded-square',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $action
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function metricCard(
|
|
string $key,
|
|
string $label,
|
|
int $value,
|
|
string $description,
|
|
string $tone,
|
|
string $icon,
|
|
array $action,
|
|
?array $chart = null,
|
|
): array
|
|
{
|
|
return array_merge([
|
|
'key' => $key,
|
|
'label' => $label,
|
|
'value' => $value,
|
|
'description' => $description,
|
|
'tone' => $tone,
|
|
'icon' => $icon,
|
|
'chart' => $chart,
|
|
], $action);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $action
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function actionCandidate(int $priority, string $key, string $title, string $reason, string $impact, string $tone, array $action): array
|
|
{
|
|
return array_merge([
|
|
'priority' => $priority,
|
|
'key' => $key,
|
|
'icon' => $this->recommendedActionIcon($key),
|
|
'title' => $title,
|
|
'reason' => $reason,
|
|
'impact' => $impact,
|
|
'tone' => $tone,
|
|
], $action);
|
|
}
|
|
|
|
private function recommendedActionIcon(string $key): string
|
|
{
|
|
return match ($key) {
|
|
'required_permissions', 'delegated_permissions', 'high_severity_findings' => 'heroicon-m-shield-exclamation',
|
|
'overdue_findings' => 'heroicon-o-clock',
|
|
'recovery_posture', 'terminal_operations', 'continue_review' => 'heroicon-o-arrow-path-rounded-square',
|
|
'risk_exceptions' => 'heroicon-o-exclamation-triangle',
|
|
default => 'heroicon-o-exclamation-triangle',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $parameters
|
|
* @return array{actionLabel:string,actionUrl:?string,actionDisabled:bool,helperText:?string}
|
|
*/
|
|
private function tenantFindingsAction(Tenant $tenant, ?User $user, string $label, array $parameters = []): array
|
|
{
|
|
$canOpen = $this->canOpenTenantCapability($tenant, $user, Capabilities::TENANT_FINDINGS_VIEW);
|
|
|
|
return $this->actionPayload(
|
|
label: $label,
|
|
url: $canOpen ? FindingResource::getUrl('index', $parameters, panel: 'tenant', tenant: $tenant) : null,
|
|
helperText: $canOpen ? null : $this->overviewText('helper_findings_requires_permissions'),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array{actionLabel:string,actionUrl:?string,actionDisabled:bool,helperText:?string}
|
|
*/
|
|
private function riskExceptionsAction(Tenant $tenant, ?User $user, string $label): array
|
|
{
|
|
$canOpen = $this->canOpenTenantCapability($tenant, $user, Capabilities::FINDING_EXCEPTION_VIEW);
|
|
|
|
return $this->actionPayload(
|
|
label: $label,
|
|
url: $canOpen ? FindingExceptionResource::getUrl('index', panel: 'tenant', tenant: $tenant) : null,
|
|
helperText: $canOpen ? null : $this->overviewText('helper_risk_exceptions_requires_permissions'),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array{actionLabel:string,actionUrl:?string,actionDisabled:bool,helperText:?string}
|
|
*/
|
|
private function tenantReviewAction(Tenant $tenant, ?User $user, string $label, ?TenantReview $review = null): array
|
|
{
|
|
$canOpen = $this->canOpenTenantCapability($tenant, $user, Capabilities::TENANT_REVIEW_VIEW);
|
|
|
|
$url = null;
|
|
|
|
if ($canOpen) {
|
|
$url = $review instanceof TenantReview
|
|
? TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant)
|
|
: TenantReviewResource::tenantScopedUrl('index', tenant: $tenant);
|
|
}
|
|
|
|
return $this->actionPayload(
|
|
label: $label,
|
|
url: $url,
|
|
helperText: $canOpen ? null : $this->overviewText('helper_review_requires_permissions'),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array{actionLabel:string,actionUrl:?string,actionDisabled:bool,helperText:?string}
|
|
*/
|
|
private function continueReviewAction(Tenant $tenant, ?User $user, TenantReview $review): array
|
|
{
|
|
$canContinue = $this->canOpenTenantCapability($tenant, $user, Capabilities::TENANT_REVIEW_MANAGE);
|
|
|
|
return $this->actionPayload(
|
|
label: $this->overviewText('action_continue_review'),
|
|
url: $canContinue ? TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant) : null,
|
|
helperText: $canContinue ? null : $this->overviewText('helper_continue_review_requires_manage'),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array{actionLabel:string,actionUrl:?string,actionDisabled:bool,helperText:?string}
|
|
*/
|
|
private function evidenceAction(Tenant $tenant, ?User $user, string $label, ?EvidenceSnapshot $snapshot = null): array
|
|
{
|
|
$canOpen = $this->canOpenTenantCapability($tenant, $user, Capabilities::EVIDENCE_VIEW);
|
|
|
|
$url = null;
|
|
|
|
if ($canOpen) {
|
|
$url = $snapshot instanceof EvidenceSnapshot
|
|
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'tenant', tenant: $tenant)
|
|
: EvidenceSnapshotResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
|
}
|
|
|
|
return $this->actionPayload(
|
|
label: $label,
|
|
url: $url,
|
|
helperText: $canOpen ? null : $this->overviewText('helper_evidence_requires_permissions'),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array{actionLabel:string,actionUrl:?string,actionDisabled:bool,helperText:?string}
|
|
*/
|
|
private function customerWorkspaceAction(Tenant $tenant, ?User $user, ?ReviewPack $reviewPack): array
|
|
{
|
|
$canOpenWorkspace = $user instanceof User
|
|
&& $user->canAccessTenant($tenant)
|
|
&& (
|
|
$user->can(Capabilities::TENANT_REVIEW_VIEW, $tenant)
|
|
|| $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)
|
|
|| $user->can(Capabilities::EVIDENCE_VIEW, $tenant)
|
|
);
|
|
|
|
$label = $reviewPack instanceof ReviewPack && (string) $reviewPack->status === 'ready'
|
|
? $this->overviewText('action_open_review_pack')
|
|
: $this->overviewText('action_view_export_artifacts');
|
|
|
|
$url = null;
|
|
|
|
if ($canOpenWorkspace) {
|
|
$url = $reviewPack instanceof ReviewPack && $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)
|
|
? ReviewPackResource::getUrl('view', ['record' => $reviewPack], panel: 'tenant', tenant: $tenant)
|
|
: CustomerReviewWorkspace::tenantPrefilterUrl($tenant);
|
|
}
|
|
|
|
return $this->actionPayload(
|
|
label: $label,
|
|
url: $url,
|
|
helperText: $canOpenWorkspace ? null : $this->overviewText('helper_customer_workspace_requires_permissions'),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array{actionLabel:string,actionUrl:?string,actionDisabled:bool,helperText:?string}
|
|
*/
|
|
private function requiredPermissionsAction(Tenant $tenant, ?User $user, string $label): array
|
|
{
|
|
$canOpen = $user instanceof User && $user->canAccessTenant($tenant);
|
|
|
|
return $this->actionPayload(
|
|
label: $label,
|
|
url: $canOpen ? RequiredPermissionsLinks::requiredPermissions($tenant) : null,
|
|
helperText: $canOpen ? null : $this->overviewText('helper_required_permissions_unavailable'),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array{actionLabel:string,actionUrl:?string,actionDisabled:bool,helperText:?string}
|
|
*/
|
|
private function operationsAction(Tenant $tenant, ?User $user, string $label, ?string $activeTab = null, ?string $problemClass = null): array
|
|
{
|
|
$canOpen = $user instanceof User && $user->canAccessTenant($tenant);
|
|
|
|
return $this->actionPayload(
|
|
label: $label,
|
|
url: $canOpen ? OperationRunLinks::index($tenant, activeTab: $activeTab, problemClass: $problemClass) : null,
|
|
helperText: $canOpen ? null : $this->overviewText('helper_operations_unavailable'),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array{actionLabel:string,actionUrl:?string,actionDisabled:bool,helperText:?string}
|
|
*/
|
|
private function baselineCompareAction(Tenant $tenant, ?User $user, string $label): array
|
|
{
|
|
$canOpen = $this->canOpenTenantCapability($tenant, $user, Capabilities::TENANT_VIEW);
|
|
|
|
return $this->actionPayload(
|
|
label: $label,
|
|
url: $canOpen ? BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant) : null,
|
|
helperText: $canOpen ? null : $this->overviewText('helper_baseline_compare_requires_permissions'),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array{actionLabel:string,actionUrl:?string,actionDisabled:bool,helperText:?string}
|
|
*/
|
|
private function backupHealthAction(Tenant $tenant, ?User $user, string $label, TenantBackupHealthAssessment $backupHealth): array
|
|
{
|
|
$canOpen = $this->canOpenTenantCapability($tenant, $user, Capabilities::TENANT_VIEW);
|
|
|
|
if (! $canOpen) {
|
|
return $this->actionPayload(
|
|
label: $label,
|
|
url: null,
|
|
helperText: $this->overviewText('helper_backup_posture_requires_permissions'),
|
|
);
|
|
}
|
|
|
|
$target = $backupHealth->primaryActionTarget;
|
|
|
|
if (! $target instanceof BackupHealthActionTarget) {
|
|
return $this->actionPayload(label: $label, url: null, helperText: null);
|
|
}
|
|
|
|
$url = match ($target->surface) {
|
|
BackupHealthActionTarget::SURFACE_BACKUP_SET_VIEW => $target->recordId !== null
|
|
? BackupSetResource::getUrl('view', ['record' => $target->recordId], panel: 'tenant', tenant: $tenant)
|
|
: BackupSetResource::getUrl('index', panel: 'tenant', tenant: $tenant),
|
|
BackupHealthActionTarget::SURFACE_BACKUP_SETS_INDEX => BackupSetResource::getUrl('index', panel: 'tenant', tenant: $tenant),
|
|
BackupHealthActionTarget::SURFACE_BACKUP_SCHEDULES_INDEX => BackupScheduleResource::getUrl('index', panel: 'tenant', tenant: $tenant),
|
|
default => null,
|
|
};
|
|
|
|
return $this->actionPayload(
|
|
label: $label,
|
|
url: $url,
|
|
helperText: $url === null ? null : null,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array{actionLabel:string,actionUrl:?string,actionDisabled:bool,helperText:?string}
|
|
*/
|
|
private function actionPayload(string $label, ?string $url, ?string $helperText): array
|
|
{
|
|
return [
|
|
'actionLabel' => $label,
|
|
'actionUrl' => $url,
|
|
'actionDisabled' => $url === null && $helperText !== null,
|
|
'helperText' => $helperText,
|
|
];
|
|
}
|
|
|
|
private function canOpenTenantCapability(Tenant $tenant, ?User $user, string $capability): bool
|
|
{
|
|
return $user instanceof User
|
|
&& $user->canAccessTenant($tenant)
|
|
&& $user->can($capability, $tenant);
|
|
}
|
|
|
|
private function reviewSurfaceActionLabel(Tenant $tenant, ?User $user, ?TenantReview $review): string
|
|
{
|
|
if (! $review instanceof TenantReview) {
|
|
return $this->overviewText('action_open_reviews');
|
|
}
|
|
|
|
return $this->canOpenTenantCapability($tenant, $user, Capabilities::TENANT_REVIEW_MANAGE)
|
|
? $this->overviewText('action_continue_review')
|
|
: $this->overviewText('action_open_review');
|
|
}
|
|
|
|
private function evidenceCoverageValue(?EvidenceSnapshot $snapshot): string
|
|
{
|
|
if (! $snapshot instanceof EvidenceSnapshot) {
|
|
return $this->overviewText('status_unavailable');
|
|
}
|
|
|
|
return BadgeRenderer::spec(BadgeDomain::EvidenceCompleteness, (string) $snapshot->completeness_state)->label;
|
|
}
|
|
|
|
private function evidenceCoverageTone(?EvidenceSnapshot $snapshot): string
|
|
{
|
|
if (! $snapshot instanceof EvidenceSnapshot) {
|
|
return 'warning';
|
|
}
|
|
|
|
return BadgeRenderer::spec(BadgeDomain::EvidenceCompleteness, (string) $snapshot->completeness_state)->color;
|
|
}
|
|
|
|
private function evidenceCoverageDescription(?EvidenceSnapshot $snapshot): string
|
|
{
|
|
if (! $snapshot instanceof EvidenceSnapshot) {
|
|
return $this->overviewText('evidence_unavailable_description');
|
|
}
|
|
|
|
return $this->overviewText('evidence_generated_prefix', ['time' => $snapshot->generated_at?->diffForHumans()]);
|
|
}
|
|
|
|
private function reviewValue(?TenantReview $review): string
|
|
{
|
|
if (! $review instanceof TenantReview) {
|
|
return $this->overviewText('status_not_ready');
|
|
}
|
|
|
|
return BadgeRenderer::spec(BadgeDomain::TenantReviewStatus, (string) $review->status)->label;
|
|
}
|
|
|
|
private function reviewTone(?TenantReview $review): string
|
|
{
|
|
if (! $review instanceof TenantReview) {
|
|
return 'warning';
|
|
}
|
|
|
|
return BadgeRenderer::spec(BadgeDomain::TenantReviewStatus, (string) $review->status)->color;
|
|
}
|
|
|
|
private function reviewDescription(?TenantReview $review): string
|
|
{
|
|
if (! $review instanceof TenantReview) {
|
|
return $this->overviewText('review_unavailable_description');
|
|
}
|
|
|
|
$timestamp = $review->published_at ?? $review->generated_at;
|
|
|
|
return $this->overviewText('review_updated_prefix', ['time' => $timestamp?->diffForHumans()]);
|
|
}
|
|
|
|
/**
|
|
* @return list<array<string, int|string>>
|
|
*/
|
|
private function reviewProgress(?TenantReview $review): array
|
|
{
|
|
if (! $review instanceof TenantReview) {
|
|
return [];
|
|
}
|
|
|
|
$summary = is_array($review->summary) ? $review->summary : [];
|
|
$progress = [];
|
|
|
|
$findingsProgress = $this->reviewFindingsProgress($summary);
|
|
|
|
if ($findingsProgress !== null) {
|
|
$progress[] = $findingsProgress;
|
|
}
|
|
|
|
$completionProgress = $this->reviewCompletionProgress($summary);
|
|
|
|
if ($completionProgress !== null) {
|
|
$progress[] = $completionProgress;
|
|
}
|
|
|
|
return $progress;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $summary
|
|
* @return array<string, int|string>|null
|
|
*/
|
|
private function reviewFindingsProgress(array $summary): ?array
|
|
{
|
|
$findingCount = (int) ($summary['finding_count'] ?? 0);
|
|
$reportBuckets = is_array($summary['finding_report_buckets'] ?? null)
|
|
? $summary['finding_report_buckets']
|
|
: null;
|
|
|
|
if ($findingCount <= 0 || $reportBuckets === null) {
|
|
return null;
|
|
}
|
|
|
|
$reviewedCount = min(
|
|
$findingCount,
|
|
max(0, array_sum(array_map(static fn (mixed $count): int => max(0, (int) $count), $reportBuckets))),
|
|
);
|
|
|
|
return $this->progressItem(
|
|
key: 'findings_with_outcome',
|
|
label: $this->overviewText('readiness_current_review_findings_progress_label'),
|
|
current: $reviewedCount,
|
|
total: $findingCount,
|
|
tone: $reviewedCount === $findingCount ? 'success' : 'primary',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $summary
|
|
* @return array<string, int|string>|null
|
|
*/
|
|
private function reviewCompletionProgress(array $summary): ?array
|
|
{
|
|
$sectionCount = (int) ($summary['section_count'] ?? 0);
|
|
$sectionStateCounts = is_array($summary['section_state_counts'] ?? null)
|
|
? $summary['section_state_counts']
|
|
: null;
|
|
|
|
if ($sectionCount <= 0 || $sectionStateCounts === null) {
|
|
return null;
|
|
}
|
|
|
|
$completeCount = min(
|
|
$sectionCount,
|
|
max(0, (int) ($sectionStateCounts['complete'] ?? 0)),
|
|
);
|
|
|
|
return $this->progressItem(
|
|
key: 'review_completion',
|
|
label: $this->overviewText('readiness_current_review_completion_progress_label'),
|
|
current: $completeCount,
|
|
total: $sectionCount,
|
|
tone: $completeCount === $sectionCount ? 'success' : 'warning',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array{key:string,label:string,current:int,total:int,percent:int,valueLabel:string,tone:string}
|
|
*/
|
|
private function progressItem(string $key, string $label, int $current, int $total, string $tone = 'primary'): array
|
|
{
|
|
$current = min($total, max(0, $current));
|
|
$percent = (int) round(($current / $total) * 100);
|
|
|
|
return [
|
|
'key' => $key,
|
|
'label' => $label,
|
|
'current' => $current,
|
|
'total' => $total,
|
|
'percent' => $percent,
|
|
'valueLabel' => sprintf('%d/%d (%d%%)', $current, $total, $percent),
|
|
'tone' => $tone,
|
|
];
|
|
}
|
|
|
|
private function providerHealthHeadline(?ProviderConnection $connection): ?string
|
|
{
|
|
if (! $connection instanceof ProviderConnection) {
|
|
return null;
|
|
}
|
|
|
|
$displayName = trim((string) ($connection->display_name ?? ''));
|
|
|
|
if ($displayName !== '') {
|
|
return $displayName;
|
|
}
|
|
|
|
if ($this->providerChipKey($connection) === 'microsoft') {
|
|
return 'Microsoft Graph';
|
|
}
|
|
|
|
return $this->providerChipLabel($connection);
|
|
}
|
|
|
|
private function providerHealthStatus(?ProviderConnection $connection): string
|
|
{
|
|
if (! $connection instanceof ProviderConnection) {
|
|
return $this->overviewText('readiness_provider_health_empty_status');
|
|
}
|
|
|
|
return BadgeRenderer::spec(BadgeDomain::ProviderVerificationStatus, $connection->verification_status)->label;
|
|
}
|
|
|
|
private function providerHealthTone(?ProviderConnection $connection): string
|
|
{
|
|
if (! $connection instanceof ProviderConnection) {
|
|
return 'gray';
|
|
}
|
|
|
|
return BadgeRenderer::spec(BadgeDomain::ProviderVerificationStatus, $connection->verification_status)->color;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $counts
|
|
* @param array<string, mixed> $freshness
|
|
*/
|
|
private function providerHealthDescription(?ProviderConnection $connection, array $counts, array $freshness): string
|
|
{
|
|
if (! $connection instanceof ProviderConnection) {
|
|
return $this->overviewText('readiness_provider_health_empty_description');
|
|
}
|
|
|
|
$missingApplication = (int) ($counts['missing_application'] ?? 0);
|
|
$missingDelegated = (int) ($counts['missing_delegated'] ?? 0);
|
|
|
|
if ($missingApplication > 0 || $missingDelegated > 0 || ($freshness['is_stale'] ?? false) === true) {
|
|
return $this->providerPermissionsDescription($counts, $freshness);
|
|
}
|
|
|
|
return match ($this->providerHealthState($connection)) {
|
|
'healthy' => $this->overviewText('provider_permissions_complete_description'),
|
|
'degraded' => $this->overviewText('readiness_provider_health_degraded_description'),
|
|
'blocked' => $this->overviewText('readiness_provider_health_blocked_description'),
|
|
'error' => $this->overviewText('readiness_provider_health_error_description'),
|
|
'pending' => $this->overviewText('readiness_provider_health_pending_description'),
|
|
default => $this->overviewText('readiness_provider_health_unknown_description'),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $overview
|
|
*/
|
|
private function providerPermissionsValue(array $overview): string
|
|
{
|
|
return match ((string) ($overview['overall'] ?? VerificationReportOverall::NeedsAttention->value)) {
|
|
VerificationReportOverall::Ready->value => $this->overviewText('provider_permissions_ready'),
|
|
VerificationReportOverall::Blocked->value => $this->overviewText('provider_permissions_blocked'),
|
|
default => $this->overviewText('provider_permissions_needs_attention'),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $overview
|
|
*/
|
|
private function providerPermissionsTone(array $overview): string
|
|
{
|
|
return match ((string) ($overview['overall'] ?? 'needs_attention')) {
|
|
VerificationReportOverall::Blocked->value => 'danger',
|
|
VerificationReportOverall::Ready->value => 'success',
|
|
default => 'warning',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $counts
|
|
* @param array<string, mixed> $freshness
|
|
*/
|
|
private function providerPermissionsDescription(array $counts, array $freshness): string
|
|
{
|
|
$missingApplication = (int) ($counts['missing_application'] ?? 0);
|
|
$missingDelegated = (int) ($counts['missing_delegated'] ?? 0);
|
|
|
|
$summary = match (true) {
|
|
$missingApplication > 0 => $this->overviewText('reason_missing_application_permissions', ['count' => $missingApplication]),
|
|
$missingDelegated > 0 => $this->overviewText('reason_missing_delegated_permissions', ['count' => $missingDelegated]),
|
|
default => $this->overviewText('provider_permissions_complete_description'),
|
|
};
|
|
|
|
if (($freshness['is_stale'] ?? false) === true) {
|
|
return $summary.' '.$this->overviewText('provider_permissions_stale_suffix');
|
|
}
|
|
|
|
return $summary;
|
|
}
|
|
|
|
private function reviewPackValue(?ReviewPack $reviewPack, ?EvidenceSnapshot $snapshot): string
|
|
{
|
|
if ($reviewPack instanceof ReviewPack) {
|
|
return BadgeRenderer::spec(BadgeDomain::ReviewPackStatus, (string) $reviewPack->status)->label;
|
|
}
|
|
|
|
if ($snapshot instanceof EvidenceSnapshot) {
|
|
return $this->overviewText('status_evidence_available');
|
|
}
|
|
|
|
return $this->overviewText('status_not_ready');
|
|
}
|
|
|
|
private function reviewPackTone(?ReviewPack $reviewPack, ?EvidenceSnapshot $snapshot): string
|
|
{
|
|
if ($reviewPack instanceof ReviewPack) {
|
|
return BadgeRenderer::spec(BadgeDomain::ReviewPackStatus, (string) $reviewPack->status)->color;
|
|
}
|
|
|
|
if ($snapshot instanceof EvidenceSnapshot) {
|
|
return 'info';
|
|
}
|
|
|
|
return 'warning';
|
|
}
|
|
|
|
private function reviewPackDescription(?ReviewPack $reviewPack, ?EvidenceSnapshot $snapshot): string
|
|
{
|
|
if ($reviewPack instanceof ReviewPack) {
|
|
return $this->overviewText('review_pack_updated_prefix', ['time' => $reviewPack->generated_at?->diffForHumans()]);
|
|
}
|
|
|
|
if ($snapshot instanceof EvidenceSnapshot) {
|
|
return $this->overviewText('review_pack_evidence_available_description');
|
|
}
|
|
|
|
return $this->overviewText('review_pack_unavailable_description');
|
|
}
|
|
|
|
private function customerSafeOutputStatus(?ReviewPack $reviewPack, ?EvidenceSnapshot $snapshot): string
|
|
{
|
|
if ($reviewPack instanceof ReviewPack) {
|
|
return $this->reviewPackValue($reviewPack, $snapshot);
|
|
}
|
|
|
|
if ($snapshot instanceof EvidenceSnapshot) {
|
|
return $this->overviewText('status_evidence_available');
|
|
}
|
|
|
|
return $this->overviewText('readiness_customer_safe_output_empty_status');
|
|
}
|
|
|
|
private function customerSafeOutputTone(?ReviewPack $reviewPack, ?EvidenceSnapshot $snapshot): string
|
|
{
|
|
if ($reviewPack instanceof ReviewPack) {
|
|
return $this->reviewPackTone($reviewPack, $snapshot);
|
|
}
|
|
|
|
if ($snapshot instanceof EvidenceSnapshot) {
|
|
return 'info';
|
|
}
|
|
|
|
return 'gray';
|
|
}
|
|
|
|
private function customerSafeOutputDescription(?ReviewPack $reviewPack, ?EvidenceSnapshot $snapshot): string
|
|
{
|
|
if ($reviewPack instanceof ReviewPack) {
|
|
return $this->reviewPackDescription($reviewPack, $snapshot);
|
|
}
|
|
|
|
if ($snapshot instanceof EvidenceSnapshot) {
|
|
return $this->overviewText('review_pack_evidence_available_description');
|
|
}
|
|
|
|
return $this->overviewText('readiness_customer_safe_output_empty_description');
|
|
}
|
|
|
|
/**
|
|
* @param array{active:int,expiring:int,expired:int,pending:int,total:int} $exceptionStats
|
|
*/
|
|
private function riskExceptionValue(array $exceptionStats): string
|
|
{
|
|
$needsAction = $exceptionStats['pending'] + $exceptionStats['expiring'] + $exceptionStats['expired'];
|
|
|
|
if ($needsAction > 0) {
|
|
return $this->overviewText('risk_exceptions_need_action_status', ['count' => $needsAction]);
|
|
}
|
|
|
|
if ($exceptionStats['active'] > 0) {
|
|
return $this->overviewText('risk_exceptions_active_status', ['count' => $exceptionStats['active']]);
|
|
}
|
|
|
|
return $this->overviewText('status_calm');
|
|
}
|
|
|
|
/**
|
|
* @param array{active:int,expiring:int,expired:int,pending:int,total:int} $exceptionStats
|
|
*/
|
|
private function riskExceptionTone(array $exceptionStats): string
|
|
{
|
|
if ($exceptionStats['expired'] > 0) {
|
|
return 'danger';
|
|
}
|
|
|
|
if ($exceptionStats['pending'] > 0 || $exceptionStats['expiring'] > 0) {
|
|
return 'warning';
|
|
}
|
|
|
|
if ($exceptionStats['active'] > 0) {
|
|
return 'info';
|
|
}
|
|
|
|
return 'success';
|
|
}
|
|
|
|
/**
|
|
* @param array{active:int,expiring:int,expired:int,pending:int,total:int} $exceptionStats
|
|
*/
|
|
private function riskExceptionDescription(array $exceptionStats): string
|
|
{
|
|
$needsAction = $exceptionStats['pending'] + $exceptionStats['expiring'] + $exceptionStats['expired'];
|
|
|
|
if ($needsAction > 0) {
|
|
return $this->overviewText('risk_exceptions_pending_description');
|
|
}
|
|
|
|
if ($exceptionStats['active'] > 0) {
|
|
return $this->overviewText('risk_exceptions_active_description');
|
|
}
|
|
|
|
return $this->overviewText('risk_exceptions_calm_description');
|
|
}
|
|
|
|
private function providerHealthState(?ProviderConnection $connection): string
|
|
{
|
|
if (! $connection instanceof ProviderConnection) {
|
|
return 'unknown';
|
|
}
|
|
|
|
$status = $connection->verification_status;
|
|
|
|
if ($status instanceof \BackedEnum) {
|
|
return (string) $status->value;
|
|
}
|
|
|
|
return trim((string) ($status ?? 'unknown')) ?: 'unknown';
|
|
}
|
|
|
|
private function relativeTime(?\DateTimeInterface $timestamp): ?string
|
|
{
|
|
return $timestamp?->diffForHumans();
|
|
}
|
|
|
|
private function relativeTimeFromString(mixed $timestamp): ?string
|
|
{
|
|
if (! is_string($timestamp) || trim($timestamp) === '') {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return Carbon::parse($timestamp)->diffForHumans();
|
|
} catch (\Throwable) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array{label:string,value:string}|null ...$items
|
|
* @return list<array{label:string,value:string}>
|
|
*/
|
|
private function cardMeta(?array ...$items): array
|
|
{
|
|
return array_values(array_filter($items, static fn (?array $item): bool => $item !== null));
|
|
}
|
|
|
|
/**
|
|
* @return array{label:string,value:string}|null
|
|
*/
|
|
private function metaItem(string $label, ?string $value): ?array
|
|
{
|
|
if (! is_string($value) || trim($value) === '') {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'label' => $label,
|
|
'value' => $value,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, scalar|null> $replace
|
|
*/
|
|
private function overviewText(string $key, array $replace = []): string
|
|
{
|
|
return (string) __('localization.dashboard.overview.'.$key, $replace);
|
|
}
|
|
} |