getKey() : (int) $tenant; $now = CarbonImmutable::now('UTC'); $latestBackupSet = $this->latestRelevantBackupSet($tenantId); $qualitySummary = $latestBackupSet instanceof BackupSet ? $this->backupQualityResolver->summarizeBackupSet($latestBackupSet) : null; $freshnessEvaluation = $this->freshnessEvaluation($latestBackupSet?->completed_at, $now); $scheduleFollowUp = $this->scheduleFollowUpEvaluation($tenantId, $now); if (! $latestBackupSet instanceof BackupSet) { return new TenantBackupHealthAssessment( tenantId: $tenantId, posture: TenantBackupHealthAssessment::POSTURE_ABSENT, primaryReason: TenantBackupHealthAssessment::REASON_NO_BACKUP_BASIS, headline: 'No usable backup basis is available.', supportingMessage: $this->combineMessages([ 'Create or finish a backup set before relying on restore input.', $scheduleFollowUp->summaryMessage, ]), latestRelevantBackupSetId: null, latestRelevantCompletedAt: null, qualitySummary: null, freshnessEvaluation: $freshnessEvaluation, scheduleFollowUp: $scheduleFollowUp, healthyClaimAllowed: false, primaryActionTarget: BackupHealthActionTarget::backupSetsIndex(TenantBackupHealthAssessment::REASON_NO_BACKUP_BASIS), positiveClaimBoundary: self::POSITIVE_CLAIM_BOUNDARY, ); } if (! $freshnessEvaluation->isFresh) { return new TenantBackupHealthAssessment( tenantId: $tenantId, posture: TenantBackupHealthAssessment::POSTURE_STALE, primaryReason: TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE, headline: 'Latest backup is stale.', supportingMessage: $this->combineMessages([ $this->latestBackupAgeMessage($latestBackupSet->completed_at, $now), $qualitySummary?->hasDegradations() === true ? 'The latest completed backup is also degraded.' : null, $scheduleFollowUp->summaryMessage, ]), latestRelevantBackupSetId: (int) $latestBackupSet->getKey(), latestRelevantCompletedAt: $latestBackupSet->completed_at, qualitySummary: $qualitySummary, freshnessEvaluation: $freshnessEvaluation, scheduleFollowUp: $scheduleFollowUp, healthyClaimAllowed: false, primaryActionTarget: BackupHealthActionTarget::backupSetView( recordId: (int) $latestBackupSet->getKey(), reason: TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE, ), positiveClaimBoundary: self::POSITIVE_CLAIM_BOUNDARY, ); } if ($qualitySummary?->hasDegradations() === true) { return new TenantBackupHealthAssessment( tenantId: $tenantId, posture: TenantBackupHealthAssessment::POSTURE_DEGRADED, primaryReason: TenantBackupHealthAssessment::REASON_LATEST_BACKUP_DEGRADED, headline: 'Latest backup is degraded.', supportingMessage: $this->combineMessages([ $qualitySummary->summaryMessage, $this->latestBackupAgeMessage($latestBackupSet->completed_at, $now), $scheduleFollowUp->summaryMessage, ]), latestRelevantBackupSetId: (int) $latestBackupSet->getKey(), latestRelevantCompletedAt: $latestBackupSet->completed_at, qualitySummary: $qualitySummary, freshnessEvaluation: $freshnessEvaluation, scheduleFollowUp: $scheduleFollowUp, healthyClaimAllowed: false, primaryActionTarget: BackupHealthActionTarget::backupSetView( recordId: (int) $latestBackupSet->getKey(), reason: TenantBackupHealthAssessment::REASON_LATEST_BACKUP_DEGRADED, ), positiveClaimBoundary: self::POSITIVE_CLAIM_BOUNDARY, ); } $scheduleNeedsFollowUp = $scheduleFollowUp->needsFollowUp; return new TenantBackupHealthAssessment( tenantId: $tenantId, posture: TenantBackupHealthAssessment::POSTURE_HEALTHY, primaryReason: $scheduleNeedsFollowUp ? TenantBackupHealthAssessment::REASON_SCHEDULE_FOLLOW_UP : null, headline: $scheduleNeedsFollowUp ? 'Backup schedules need follow-up.' : 'Backups are recent and healthy.', supportingMessage: $scheduleNeedsFollowUp ? $this->combineMessages([ 'The latest completed backup is recent and shows no material degradation.', $scheduleFollowUp->summaryMessage, ]) : $this->latestBackupAgeMessage($latestBackupSet->completed_at, $now), latestRelevantBackupSetId: (int) $latestBackupSet->getKey(), latestRelevantCompletedAt: $latestBackupSet->completed_at, qualitySummary: $qualitySummary, freshnessEvaluation: $freshnessEvaluation, scheduleFollowUp: $scheduleFollowUp, healthyClaimAllowed: ! $scheduleNeedsFollowUp, primaryActionTarget: $scheduleNeedsFollowUp ? BackupHealthActionTarget::backupSchedulesIndex(TenantBackupHealthAssessment::REASON_SCHEDULE_FOLLOW_UP) : null, positiveClaimBoundary: self::POSITIVE_CLAIM_BOUNDARY, ); } private function latestRelevantBackupSet(int $tenantId): ?BackupSet { /** @var BackupSet|null $backupSet */ $backupSet = BackupSet::query() ->withTrashed() ->where('tenant_id', $tenantId) ->whereNotNull('completed_at') ->orderByDesc('completed_at') ->orderByDesc('id') ->with([ 'items' => fn ($query) => $query->select([ 'id', 'tenant_id', 'backup_set_id', 'payload', 'metadata', 'assignments', ]), ]) ->first([ 'id', 'tenant_id', 'workspace_id', 'name', 'status', 'item_count', 'created_by', 'completed_at', 'created_at', 'metadata', 'deleted_at', ]); return $backupSet; } private function freshnessEvaluation(?CarbonInterface $latestCompletedAt, CarbonImmutable $now): BackupFreshnessEvaluation { $cutoffAt = $now->subHours($this->freshnessHours()); return new BackupFreshnessEvaluation( latestCompletedAt: $latestCompletedAt, cutoffAt: $cutoffAt, isFresh: $latestCompletedAt?->greaterThanOrEqualTo($cutoffAt) ?? false, ); } private function scheduleFollowUpEvaluation(int $tenantId, CarbonImmutable $now): BackupScheduleFollowUpEvaluation { $graceCutoff = $now->subMinutes($this->scheduleOverdueGraceMinutes()); $schedules = BackupSchedule::query() ->where('tenant_id', $tenantId) ->where('is_enabled', true) ->orderBy('next_run_at') ->orderBy('id') ->get([ 'id', 'tenant_id', 'last_run_status', 'last_run_at', 'next_run_at', 'created_at', ]); $enabledScheduleCount = $schedules->count(); $overdueScheduleCount = 0; $failedRecentRunCount = 0; $neverSuccessfulCount = 0; $primaryScheduleId = null; foreach ($schedules as $schedule) { $isOverdue = $schedule->next_run_at?->lessThan($graceCutoff) ?? false; $lastRunStatus = strtolower(trim((string) $schedule->last_run_status)); $needsFollowUpAfterRun = in_array($lastRunStatus, ['failed', 'partial', 'skipped', 'canceled'], true); $neverSuccessful = $schedule->last_run_at === null && ($isOverdue || ($schedule->created_at?->lessThan($graceCutoff) ?? false)); if ($isOverdue) { $overdueScheduleCount++; } if ($needsFollowUpAfterRun) { $failedRecentRunCount++; } if ($neverSuccessful) { $neverSuccessfulCount++; } if ($primaryScheduleId === null && ($neverSuccessful || $isOverdue || $needsFollowUpAfterRun)) { $primaryScheduleId = (int) $schedule->getKey(); } } return new BackupScheduleFollowUpEvaluation( hasEnabledSchedules: $enabledScheduleCount > 0, enabledScheduleCount: $enabledScheduleCount, overdueScheduleCount: $overdueScheduleCount, failedRecentRunCount: $failedRecentRunCount, neverSuccessfulCount: $neverSuccessfulCount, needsFollowUp: $overdueScheduleCount > 0 || $failedRecentRunCount > 0 || $neverSuccessfulCount > 0, primaryScheduleId: $primaryScheduleId, summaryMessage: $this->scheduleSummaryMessage( enabledScheduleCount: $enabledScheduleCount, overdueScheduleCount: $overdueScheduleCount, failedRecentRunCount: $failedRecentRunCount, neverSuccessfulCount: $neverSuccessfulCount, ), ); } private function latestBackupAgeMessage(?CarbonInterface $completedAt, CarbonImmutable $now): ?string { if (! $completedAt instanceof CarbonInterface) { return null; } return sprintf( 'The latest completed backup was %s.', $completedAt->diffForHumans($now, [ 'parts' => 2, 'join' => true, 'short' => false, 'syntax' => CarbonInterface::DIFF_RELATIVE_TO_NOW, ]), ); } private function freshnessHours(): int { return max(1, (int) config('tenantpilot.backup_health.freshness_hours', 24)); } private function scheduleOverdueGraceMinutes(): int { return max(1, (int) config('tenantpilot.backup_health.schedule_overdue_grace_minutes', 30)); } private function scheduleSummaryMessage( int $enabledScheduleCount, int $overdueScheduleCount, int $failedRecentRunCount, int $neverSuccessfulCount, ): ?string { if ($enabledScheduleCount === 0) { return null; } if ($neverSuccessfulCount > 0) { return $neverSuccessfulCount === 1 ? 'One enabled schedule has not produced a successful run yet.' : sprintf('%d enabled schedules have not produced a successful run yet.', $neverSuccessfulCount); } if ($overdueScheduleCount > 0) { return $overdueScheduleCount === 1 ? 'One enabled schedule looks overdue.' : sprintf('%d enabled schedules look overdue.', $overdueScheduleCount); } if ($failedRecentRunCount > 0) { return $failedRecentRunCount === 1 ? 'One enabled schedule needs follow-up after the last run.' : sprintf('%d enabled schedules need follow-up after the last run.', $failedRecentRunCount); } return null; } /** * @param array $messages */ private function combineMessages(array $messages): ?string { $parts = array_values(array_filter( Arr::flatten($messages), static fn (mixed $message): bool => is_string($message) && $message !== '' )); if ($parts === []) { return null; } return implode(' ', $parts); } }