mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-12 15:53:46 -04:00
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:
co-authored by
Claude Opus 5.5
parent
4475273c8a
commit
a5acf81751
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user