diff --git a/app/Jobs/ScheduledJobManager.php b/app/Jobs/ScheduledJobManager.php
index fd641abb0..4195a1946 100644
--- a/app/Jobs/ScheduledJobManager.php
+++ b/app/Jobs/ScheduledJobManager.php
@@ -341,8 +341,19 @@ class ScheduledJobManager implements ShouldQueue
$lastDispatched = Cache::get($dedupKey);
- // Run if: never dispatched before, OR there's been a due time since last dispatch
- if ($lastDispatched === null || $previousDue->gt(Carbon::parse($lastDispatched))) {
+ if ($lastDispatched === null) {
+ // First run after restart or cache loss: only fire if actually due right now.
+ // Seed the cache so subsequent runs can use tolerance/catch-up logic.
+ $isDue = $cron->isDue($executionTime);
+ if ($isDue) {
+ Cache::put($dedupKey, $executionTime->toIso8601String(), 86400);
+ }
+
+ return $isDue;
+ }
+
+ // Subsequent runs: fire if there's been a due time since last dispatch
+ if ($previousDue->gt(Carbon::parse($lastDispatched))) {
Cache::put($dedupKey, $executionTime->toIso8601String(), 86400);
return true;
diff --git a/app/Livewire/Settings/ScheduledJobs.php b/app/Livewire/Settings/ScheduledJobs.php
index 4947dd19b..1e54f1483 100644
--- a/app/Livewire/Settings/ScheduledJobs.php
+++ b/app/Livewire/Settings/ScheduledJobs.php
@@ -3,8 +3,11 @@
namespace App\Livewire\Settings;
use App\Models\DockerCleanupExecution;
+use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledDatabaseBackupExecution;
+use App\Models\ScheduledTask;
use App\Models\ScheduledTaskExecution;
+use App\Models\Server;
use App\Services\SchedulerLogParser;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
@@ -102,13 +105,84 @@ class ScheduledJobs extends Component
$parser = new SchedulerLogParser;
$allSkips = $parser->getRecentSkips(500, $teamId);
$this->skipTotalCount = $allSkips->count();
- $this->skipLogs = $allSkips->slice($this->skipPage, $this->skipDefaultTake)->values();
+ $this->skipLogs = $this->enrichSkipLogsWithLinks(
+ $allSkips->slice($this->skipPage, $this->skipDefaultTake)->values()
+ );
$this->showSkipPrev = $this->skipPage > 0;
$this->showSkipNext = ($this->skipPage + $this->skipDefaultTake) < $this->skipTotalCount;
$this->skipCurrentPage = intval($this->skipPage / $this->skipDefaultTake) + 1;
$this->managerRuns = $parser->getRecentRuns(30, $teamId);
}
+ private function enrichSkipLogsWithLinks(Collection $skipLogs): Collection
+ {
+ $taskIds = $skipLogs->where('type', 'task')->pluck('context.task_id')->filter()->unique()->values();
+ $backupIds = $skipLogs->where('type', 'backup')->pluck('context.backup_id')->filter()->unique()->values();
+ $serverIds = $skipLogs->where('type', 'docker_cleanup')->pluck('context.server_id')->filter()->unique()->values();
+
+ $tasks = $taskIds->isNotEmpty()
+ ? ScheduledTask::with(['application.environment.project', 'service.environment.project'])->whereIn('id', $taskIds)->get()->keyBy('id')
+ : collect();
+
+ $backups = $backupIds->isNotEmpty()
+ ? ScheduledDatabaseBackup::with(['database.environment.project'])->whereIn('id', $backupIds)->get()->keyBy('id')
+ : collect();
+
+ $servers = $serverIds->isNotEmpty()
+ ? Server::whereIn('id', $serverIds)->get()->keyBy('id')
+ : collect();
+
+ return $skipLogs->map(function (array $skip) use ($tasks, $backups, $servers): array {
+ $skip['link'] = null;
+ $skip['resource_name'] = null;
+
+ if ($skip['type'] === 'task') {
+ $task = $tasks->get($skip['context']['task_id'] ?? null);
+ if ($task) {
+ $skip['resource_name'] = $skip['context']['task_name'] ?? $task->name;
+ $resource = $task->application ?? $task->service;
+ $environment = $resource?->environment;
+ $project = $environment?->project;
+ if ($project && $environment && $resource) {
+ $routeName = $task->application_id
+ ? 'project.application.scheduled-tasks'
+ : 'project.service.scheduled-tasks';
+ $routeKey = $task->application_id ? 'application_uuid' : 'service_uuid';
+ $skip['link'] = route($routeName, [
+ 'project_uuid' => $project->uuid,
+ 'environment_uuid' => $environment->uuid,
+ $routeKey => $resource->uuid,
+ 'task_uuid' => $task->uuid,
+ ]);
+ }
+ }
+ } elseif ($skip['type'] === 'backup') {
+ $backup = $backups->get($skip['context']['backup_id'] ?? null);
+ 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,
+ ]);
+ }
+ }
+ } elseif ($skip['type'] === 'docker_cleanup') {
+ $server = $servers->get($skip['context']['server_id'] ?? null);
+ if ($server) {
+ $skip['resource_name'] = $server->name;
+ $skip['link'] = route('server.show', ['server_uuid' => $server->uuid]);
+ }
+ }
+
+ return $skip;
+ });
+ }
+
private function getExecutions(?int $teamId = null): Collection
{
$dateFrom = $this->getDateFrom();
diff --git a/resources/views/livewire/settings/scheduled-jobs.blade.php b/resources/views/livewire/settings/scheduled-jobs.blade.php
index 60acc9062..7c0db860f 100644
--- a/resources/views/livewire/settings/scheduled-jobs.blade.php
+++ b/resources/views/livewire/settings/scheduled-jobs.blade.php
@@ -213,8 +213,8 @@
| Time |
Type |
+ Resource |
Reason |
- Details |
@@ -235,6 +235,17 @@
{{ ucfirst(str_replace('_', ' ', $skip['type'])) }}
+
+ @if($skip['link'] ?? null)
+
+ {{ $skip['resource_name'] }}
+
+ @elseif($skip['resource_name'] ?? null)
+ {{ $skip['resource_name'] }}
+ @else
+ {{ $skip['context']['task_name'] ?? $skip['context']['server_name'] ?? 'Deleted' }}
+ @endif
+ |
@php
$reasonLabel = match($skip['reason']) {
@@ -256,15 +267,6 @@
@endphp
{{ $reasonLabel }}
|
-
- @php
- $details = collect($skip['context'])
- ->except(['type', 'skip_reason', 'execution_time'])
- ->map(fn($v, $k) => str_replace('_', ' ', $k) . ': ' . $v)
- ->implode(', ');
- @endphp
- {{ $details }}
- |
@empty
diff --git a/tests/Feature/ScheduledJobManagerShouldRunNowTest.php b/tests/Feature/ScheduledJobManagerShouldRunNowTest.php
index 8862cc71e..f820c3777 100644
--- a/tests/Feature/ScheduledJobManagerShouldRunNowTest.php
+++ b/tests/Feature/ScheduledJobManagerShouldRunNowTest.php
@@ -30,8 +30,11 @@ it('dispatches backup when job runs on time at the cron minute', function () {
expect($result)->toBeTrue();
});
-it('dispatches backup when job is delayed past the cron minute', function () {
- // Freeze time at 02:07 — job was delayed 7 minutes past the 02:00 cron
+it('catches delayed job when cache has a baseline from previous run', function () {
+ // Simulate a previous dispatch yesterday at 02:00
+ Cache::put('test-backup:1', Carbon::create(2026, 2, 27, 2, 0, 0, 'UTC')->toIso8601String(), 86400);
+
+ // Freeze time at 02:07 — job was delayed 7 minutes past today's 02:00 cron
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 7, 0, 'UTC'));
$job = new ScheduledJobManager;
@@ -45,8 +48,8 @@ it('dispatches backup when job is delayed past the cron minute', function () {
$method = $reflection->getMethod('shouldRunNow');
$method->setAccessible(true);
- // isDue() would return false at 02:07, but getPreviousRunDate() = 02:00
- // No lastDispatched in cache → should run
+ // isDue() would return false at 02:07, but getPreviousRunDate() = 02:00 today
+ // lastDispatched = 02:00 yesterday → 02:00 today > yesterday → fires
$result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:1');
expect($result)->toBeTrue();
@@ -106,8 +109,27 @@ it('fires every_minute cron correctly on consecutive minutes', function () {
expect($result3)->toBeTrue();
});
-it('re-dispatches after cache loss instead of missing', function () {
- // First run at 02:00 — dispatches
+it('does not fire non-due jobs on restart when cache is empty', function () {
+ // Time is 10:00, cron is daily at 02:00 — NOT due right now
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC'));
+
+ $job = new ScheduledJobManager;
+ $reflection = new ReflectionClass($job);
+
+ $executionTimeProp = $reflection->getProperty('executionTime');
+ $executionTimeProp->setAccessible(true);
+ $executionTimeProp->setValue($job, Carbon::now());
+
+ $method = $reflection->getMethod('shouldRunNow');
+ $method->setAccessible(true);
+
+ // Cache is empty (fresh restart) — should NOT fire daily backup at 10:00
+ $result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:4');
+ expect($result)->toBeFalse();
+});
+
+it('fires due jobs on restart when cache is empty', function () {
+ // Time is exactly 02:00, cron is daily at 02:00 — IS due right now
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC'));
$job = new ScheduledJobManager;
@@ -120,16 +142,8 @@ it('re-dispatches after cache loss instead of missing', function () {
$method = $reflection->getMethod('shouldRunNow');
$method->setAccessible(true);
- $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:4');
-
- // Simulate Redis restart — cache lost
- Cache::forget('test-backup:4');
-
- // Run again at 02:01 — should re-dispatch (lastDispatched = null after cache loss)
- Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 1, 0, 'UTC'));
- $executionTimeProp->setValue($job, Carbon::now());
-
- $result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:4');
+ // Cache is empty (fresh restart) — but cron IS due → should fire
+ $result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:4b');
expect($result)->toBeTrue();
});
diff --git a/tests/Feature/ScheduledJobMonitoringTest.php b/tests/Feature/ScheduledJobMonitoringTest.php
index 90a3f57a4..036c3b638 100644
--- a/tests/Feature/ScheduledJobMonitoringTest.php
+++ b/tests/Feature/ScheduledJobMonitoringTest.php
@@ -234,3 +234,39 @@ test('scheduler log parser filters by team id', function () {
// Cleanup
@unlink($logPath);
});
+
+test('skipped jobs show fallback when resource is deleted', function () {
+ $this->actingAs($this->rootUser);
+ session(['currentTeam' => $this->rootTeam]);
+
+ $logPath = storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log');
+ $logDir = dirname($logPath);
+ if (! is_dir($logDir)) {
+ mkdir($logDir, 0755, true);
+ }
+
+ // Temporarily rename existing logs so they don't interfere
+ $existingLogs = glob(storage_path('logs/scheduled-*.log'));
+ $renamed = [];
+ foreach ($existingLogs as $log) {
+ $tmp = $log.'.bak';
+ rename($log, $tmp);
+ $renamed[$tmp] = $log;
+ }
+
+ $lines = [
+ '['.now()->format('Y-m-d H:i:s').'] production.INFO: Task skipped {"type":"task","skip_reason":"application_not_running","task_id":99999,"task_name":"my-cron-job","team_id":0}',
+ ];
+ file_put_contents($logPath, implode("\n", $lines)."\n");
+
+ Livewire::test(ScheduledJobs::class)
+ ->assertStatus(200)
+ ->assertSee('my-cron-job')
+ ->assertSee('Application not running');
+
+ // Cleanup
+ @unlink($logPath);
+ foreach ($renamed as $tmp => $original) {
+ rename($tmp, $original);
+ }
+});