fix(scheduled-tasks): stop 404 after deleting a scheduled task

The executions list polled with the task stored as a model, so a poll
after the delete failed to load it and showed a 404. Load the task by id
on each request and skip the refresh when it is gone. Return true from
delete so the confirmation modal does not refresh the deleted task.

Closes #11814

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-10-16 00:32:13 +02:00
co-authored by Claude Opus 5.5
parent 4475273c8a
commit a5acf81751
6 changed files with 235 additions and 18 deletions
@@ -6,6 +6,7 @@ use App\Models\ScheduledTask;
use App\Traits\ListensToTeamChannel;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Locked;
use Livewire\Component;
@@ -13,9 +14,6 @@ class Executions extends Component
{
use ListensToTeamChannel;
#[Locked]
public ScheduledTask $task;
#[Locked]
public int $taskId;
@@ -48,11 +46,11 @@ class Executions extends Component
{
try {
$this->taskId = $taskId;
$this->task = ScheduledTask::where('team_id', Auth::user()->currentTeam()->id)->findOrFail($taskId);
$this->executions = $this->task->executions()->take(20)->get();
$this->serverTimezone = data_get($this->task, 'application.destination.server.settings.server_timezone');
$task = ScheduledTask::where('team_id', Auth::user()->currentTeam()->id)->findOrFail($taskId);
$this->executions = $task->executions()->take(20)->get();
$this->serverTimezone = data_get($task, 'application.destination.server.settings.server_timezone');
if (! $this->serverTimezone) {
$this->serverTimezone = data_get($this->task, 'service.destination.server.settings.server_timezone');
$this->serverTimezone = data_get($task, 'service.destination.server.settings.server_timezone');
}
if (! $this->serverTimezone) {
$this->serverTimezone = 'UTC';
@@ -62,8 +60,22 @@ class Executions extends Component
}
}
/**
* Load the task on each request instead of storing it as a model property. A stored model fails
* with a 404 when the poll runs after the task is deleted and before the page navigates away.
*/
#[Computed]
public function task(): ?ScheduledTask
{
return ScheduledTask::find($this->taskId);
}
public function refreshExecutions(): void
{
if (! $this->task) {
return;
}
$this->executions = $this->task->executions()->take(20)->get();
if ($this->selectedKey) {
$this->selectedExecution = $this->task->executions()->find($this->selectedKey);
@@ -84,7 +96,7 @@ class Executions extends Component
return;
}
$this->selectedKey = $key;
$this->selectedExecution = $this->task->executions()->find($key);
$this->selectedExecution = $this->task?->executions()->find($key);
$this->currentPage = 1;
// Start polling if task is running
@@ -157,7 +169,9 @@ class Executions extends Component
*/
public function canReadOutput(): bool
{
return auth()->user()?->can('update', $this->task->application ?? $this->task->service) ?? false;
$resource = $this->task?->application ?? $this->task?->service;
return $resource && auth()->user()?->can('update', $resource);
}
public function hasMoreLogs()
@@ -179,11 +179,10 @@ class Show extends Component
$this->task->delete();
auditLog('ui.scheduled_task.deleted', $this->taskAuditContext());
if ($this->type === 'application') {
return redirectRoute($this, 'project.application.scheduled-tasks.show', $this->parameters);
} else {
return redirectRoute($this, 'project.service.scheduled-tasks.show', $this->parameters);
}
redirectRoute($this, "project.{$this->type}.scheduled-tasks.show", $this->parameters);
// Without true, the confirmation modal refreshes this deleted task while wire:navigate loads the list.
return true;
} catch (\Exception $e) {
return handleError($e);
}
@@ -25,8 +25,8 @@
'failed' => 'Failed',
default => ucfirst($executionStatus),
};
$server = data_get($task, 'application.destination.server')
?? data_get($task, 'service.destination.server');
$server = data_get($this->task, 'application.destination.server')
?? data_get($this->task, 'service.destination.server');
@endphp
<div class="border-b border-neutral-200 last:border-b-0 dark:border-white/[0.07]"
wire:key="task-execution-{{ data_get($execution, 'id') }}">
@@ -60,8 +60,8 @@ describe('ScheduledTask locked properties', function () {
expect($attributes)->not->toBeEmpty();
});
test('Executions component task property has Locked attribute', function () {
$property = new ReflectionProperty(Executions::class, 'task');
test('Executions component task id property has Locked attribute', function () {
$property = new ReflectionProperty(Executions::class, 'taskId');
$attributes = $property->getAttributes(Locked::class);
expect($attributes)->not->toBeEmpty();
+127
View File
@@ -0,0 +1,127 @@
<?php
use App\Livewire\Project\Shared\ScheduledTask\Executions;
use App\Livewire\Project\Shared\ScheduledTask\Show;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\ScheduledTask;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Routing\Events\RouteMatched;
use Illuminate\Support\Facades\Event;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
InstanceSettings::forceCreate(['id' => 0]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
$server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $privateKey->id]);
$this->destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
afterEach(function () {
Event::forget(RouteMatched::class);
});
/**
* @param array<string, string> $parameters
*/
function bindScheduledTaskDeleteRoute(array $parameters): void
{
Event::listen(RouteMatched::class, function (RouteMatched $event) use ($parameters): void {
foreach ($parameters as $name => $value) {
$event->route->setParameter($name, $value);
}
});
}
test('deleting a service scheduled task returns true so the confirmation modal does not refresh the deleted task', function () {
$service = Service::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$task = ScheduledTask::factory()->create([
'team_id' => $this->team->id,
'service_id' => $service->id,
'name' => 'nightly',
'command' => 'echo nightly',
'frequency' => 'daily',
]);
$parameters = [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'service_uuid' => $service->uuid,
];
bindScheduledTaskDeleteRoute([...$parameters, 'task_uuid' => $task->uuid]);
Livewire::test(Show::class)
->call('delete')
->assertReturned(true)
->assertRedirect(route('project.service.scheduled-tasks.show', $parameters));
expect($task->fresh())->toBeNull();
});
test('deleting an application scheduled task returns true and redirects to the task list', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$task = ScheduledTask::factory()->create([
'team_id' => $this->team->id,
'application_id' => $application->id,
'name' => 'nightly',
'command' => 'echo nightly',
'frequency' => 'daily',
]);
$parameters = [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'application_uuid' => $application->uuid,
];
bindScheduledTaskDeleteRoute([...$parameters, 'task_uuid' => $task->uuid]);
Livewire::test(Show::class)
->call('delete')
->assertReturned(true)
->assertRedirect(route('project.application.scheduled-tasks.show', $parameters));
expect($task->fresh())->toBeNull();
});
test('the executions poll still works after its task is deleted while the page navigates away', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$task = ScheduledTask::factory()->create([
'team_id' => $this->team->id,
'application_id' => $application->id,
]);
$component = Livewire::test(Executions::class, ['taskId' => $task->id]);
$task->delete();
$component->call('refreshExecutions')->assertOk();
});
@@ -0,0 +1,77 @@
<?php
use App\Models\ScheduledTask;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->stack = seedBrowserResourceStack();
$service = createBrowserService($this->stack, ['uuid' => 'svc-browser-task', 'name' => 'Task Service'])['service'];
$this->task = ScheduledTask::factory()->create([
'team_id' => 0,
'service_id' => $service->id,
'name' => 'nightly-cleanup',
'command' => 'echo cleanup',
'frequency' => 'daily',
]);
$this->base = serviceConfigurationUrl($this->stack['project'], $this->stack['environment'], $service);
});
/**
* Records the HTTP status of every Livewire request the page sends.
*/
function recordScheduledTaskLivewireStatuses(mixed $page): void
{
$page->script(<<<'JS'
(() => {
window.livewireStatuses = [];
const fetch = window.fetch;
window.fetch = async (...args) => {
const response = await fetch(...args);
if (String(args[0]).includes('/livewire')) {
window.livewireStatuses.push(response.status);
}
return response;
};
})()
JS);
}
it('deletes a service scheduled task and returns to the task list', function () {
loginAndSkipBoarding();
$page = visit("{$this->base}/tasks/{$this->task->uuid}")
->assertSee('Task configuration');
recordScheduledTaskLivewireStatuses($page);
$page->click('button:visible:has-text("Delete")')
->fill('input[x-model="userConfirmationText"]', 'nightly-cleanup')
->click('button:visible:has-text("Permanently delete")')
->wait(3);
expect($this->task->fresh())->toBeNull()
->and($page->script('window.livewireStatuses'))->not->toContain(404);
$page->assertPathIs(parse_url("{$this->base}/scheduled-tasks", PHP_URL_PATH))
->screenshot(filename: 'scheduled-task-deleted');
});
it('keeps polling executions without a 404 after the task is deleted', function () {
loginAndSkipBoarding();
$page = visit("{$this->base}/tasks/{$this->task->uuid}")
->assertSee('Recent executions');
recordScheduledTaskLivewireStatuses($page);
// The executions list polls every 5 seconds, so a poll can run after the delete and before the list page loads.
$this->task->delete();
$page->script(<<<'JS'
Livewire.all().find((component) => component.name === 'project.shared.scheduled-task.executions')
.$wire.refreshExecutions()
JS);
$page->wait(1);
expect($page->script('window.livewireStatuses'))->toBe([200]);
$page->screenshot(filename: 'scheduled-task-poll-after-delete');
});