TenantAtlas/apps/platform/app/Support/TenantDashboard/TenantDashboardSummaryBuilder.php
Ahmed Darrazi beebbaefbe
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 5m58s
chore: commit all local changes
2026-05-03 16:00:44 +02:00

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);
}
}