diff --git a/app/Livewire/Settings/ScheduledJobs.php b/app/Livewire/Settings/ScheduledJobs.php index 1e54f1483..3655329b1 100644 --- a/app/Livewire/Settings/ScheduledJobs.php +++ b/app/Livewire/Settings/ScheduledJobs.php @@ -8,6 +8,15 @@ use App\Models\ScheduledDatabaseBackupExecution; use App\Models\ScheduledTask; use App\Models\ScheduledTaskExecution; use App\Models\Server; +use App\Models\ServiceDatabase; +use App\Models\StandaloneClickhouse; +use App\Models\StandaloneDragonfly; +use App\Models\StandaloneKeydb; +use App\Models\StandaloneMariadb; +use App\Models\StandaloneMongodb; +use App\Models\StandaloneMysql; +use App\Models\StandalonePostgresql; +use App\Models\StandaloneRedis; use App\Services\SchedulerLogParser; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; @@ -125,7 +134,21 @@ class ScheduledJobs extends Component : collect(); $backups = $backupIds->isNotEmpty() - ? ScheduledDatabaseBackup::with(['database.environment.project'])->whereIn('id', $backupIds)->get()->keyBy('id') + ? ScheduledDatabaseBackup::with('database') + ->whereIn('id', $backupIds) + ->get() + ->loadMorph('database', [ + ServiceDatabase::class => ['service.environment.project'], + StandaloneClickhouse::class => ['environment.project'], + StandaloneDragonfly::class => ['environment.project'], + StandaloneKeydb::class => ['environment.project'], + StandaloneMariadb::class => ['environment.project'], + StandaloneMongodb::class => ['environment.project'], + StandaloneMysql::class => ['environment.project'], + StandalonePostgresql::class => ['environment.project'], + StandaloneRedis::class => ['environment.project'], + ]) + ->keyBy('id') : collect(); $servers = $serverIds->isNotEmpty() @@ -161,14 +184,29 @@ class ScheduledJobs extends Component if ($backup) { $database = $backup->database; $skip['resource_name'] = $database?->name ?? 'Database backup'; - $environment = $database?->environment; - $project = $environment?->project; - if ($project && $environment && $database) { - $skip['link'] = route('project.database.backup.index', [ - 'project_uuid' => $project->uuid, - 'environment_uuid' => $environment->uuid, - 'database_uuid' => $database->uuid, - ]); + + if ($database instanceof ServiceDatabase) { + $service = $database->service; + $environment = $service?->environment; + $project = $environment?->project; + if ($project && $environment && $service) { + $skip['link'] = route('project.service.database.backups', [ + 'project_uuid' => $project->uuid, + 'environment_uuid' => $environment->uuid, + 'service_uuid' => $service->uuid, + 'stack_service_uuid' => $database->uuid, + ]); + } + } else { + $environment = $database?->environment; + $project = $environment?->project; + if ($project && $environment && $database) { + $skip['link'] = route('project.database.backup.index', [ + 'project_uuid' => $project->uuid, + 'environment_uuid' => $environment->uuid, + 'database_uuid' => $database->uuid, + ]); + } } } } elseif ($skip['type'] === 'docker_cleanup') { diff --git a/tests/Feature/ScheduledJobMonitoringTest.php b/tests/Feature/ScheduledJobMonitoringTest.php index 036c3b638..6801151fb 100644 --- a/tests/Feature/ScheduledJobMonitoringTest.php +++ b/tests/Feature/ScheduledJobMonitoringTest.php @@ -2,9 +2,15 @@ use App\Livewire\Settings\ScheduledJobs; use App\Models\DockerCleanupExecution; +use App\Models\Environment; +use App\Models\Project; use App\Models\ScheduledDatabaseBackup; use App\Models\ScheduledDatabaseBackupExecution; use App\Models\Server; +use App\Models\Service; +use App\Models\ServiceDatabase; +use App\Models\StandaloneDocker; +use App\Models\StandalonePostgresql; use App\Models\Team; use App\Models\User; use App\Services\SchedulerLogParser; @@ -13,6 +19,35 @@ use Livewire\Livewire; uses(RefreshDatabase::class); +function withIsolatedScheduledLogsForMonitoringTest(callable $callback): mixed +{ + $logDir = storage_path('logs'); + if (! is_dir($logDir)) { + mkdir($logDir, 0755, true); + } + + $renamed = []; + foreach (glob($logDir.'/scheduled-*.log') as $log) { + $tmp = $log.'.scheduled-jobs-test-bak'; + rename($log, $tmp); + $renamed[$tmp] = $log; + } + + try { + return $callback($logDir.'/scheduled-'.now()->format('Y-m-d').'.log'); + } finally { + foreach (glob($logDir.'/scheduled-*.log') as $log) { + @unlink($log); + } + + foreach ($renamed as $tmp => $original) { + if (file_exists($tmp)) { + rename($tmp, $original); + } + } + } +} + beforeEach(function () { // Create root team (id 0) and root user $this->rootTeam = Team::factory()->create(['id' => 0, 'name' => 'Root Team']); @@ -270,3 +305,96 @@ test('skipped jobs show fallback when resource is deleted', function () { rename($tmp, $original); } }); + +test('skipped service database backups render with service backup link', function () { + $this->actingAs($this->rootUser); + session(['currentTeam' => $this->rootTeam]); + + $server = Server::factory()->create(['team_id' => $this->rootTeam->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->rootTeam->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $service = Service::factory()->create([ + 'server_id' => $server->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'environment_id' => $environment->id, + ]); + $serviceDatabase = ServiceDatabase::create([ + 'service_id' => $service->id, + 'name' => 'service-postgres', + 'image' => 'postgres:16-alpine', + 'custom_type' => 'postgresql', + ]); + $backup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->rootTeam->id, + 'frequency' => '0 * * * *', + 'database_id' => $serviceDatabase->id, + 'database_type' => $serviceDatabase->getMorphClass(), + 'enabled' => true, + ]); + + withIsolatedScheduledLogsForMonitoringTest(function (string $logPath) use ($backup, $project, $environment, $service, $serviceDatabase) { + file_put_contents( + $logPath, + '['.now()->format('Y-m-d H:i:s').'] production.INFO: Backup skipped {"type":"backup","skip_reason":"server_not_functional","backup_id":'.$backup->id.',"team_id":'.$this->rootTeam->id.'}'."\n" + ); + + $expectedUrl = route('project.service.database.backups', [ + 'project_uuid' => $project->uuid, + 'environment_uuid' => $environment->uuid, + 'service_uuid' => $service->uuid, + 'stack_service_uuid' => $serviceDatabase->uuid, + ]); + + Livewire::test(ScheduledJobs::class) + ->assertOk() + ->assertSee('service-postgres') + ->assertSeeHtml('href="'.$expectedUrl.'"'); + }); +}); + +test('skipped standalone database backups keep standalone backup link', function () { + $this->actingAs($this->rootUser); + session(['currentTeam' => $this->rootTeam]); + + $server = Server::factory()->create(['team_id' => $this->rootTeam->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->rootTeam->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $database = StandalonePostgresql::create([ + 'name' => 'standalone-postgres', + 'image' => 'postgres:16-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $backup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->rootTeam->id, + 'frequency' => '0 * * * *', + 'database_id' => $database->id, + 'database_type' => $database->getMorphClass(), + 'enabled' => true, + ]); + + withIsolatedScheduledLogsForMonitoringTest(function (string $logPath) use ($backup, $project, $environment, $database) { + file_put_contents( + $logPath, + '['.now()->format('Y-m-d H:i:s').'] production.INFO: Backup skipped {"type":"backup","skip_reason":"server_not_functional","backup_id":'.$backup->id.',"team_id":'.$this->rootTeam->id.'}'."\n" + ); + + $expectedUrl = route('project.database.backup.index', [ + 'project_uuid' => $project->uuid, + 'environment_uuid' => $environment->uuid, + 'database_uuid' => $database->uuid, + ]); + + Livewire::test(ScheduledJobs::class) + ->assertOk() + ->assertSee('standalone-postgres') + ->assertSeeHtml('href="'.$expectedUrl.'"'); + }); +});