From f86a02f409747924c11f5fa0d031bb8ee1ec6287 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:39:26 +0200 Subject: [PATCH 1/2] fix(logs): fall back to default level filters when saved filters are invalid Malformed JSON in the coolify-log-filters localStorage key threw during runtime log viewer startup. Read the saved value safely and keep only boolean values for known levels. Fixes #12101 --- resources/js/runtime-logs.js | 19 +++++++++++++++++- .../Browser/RuntimeLogsVirtualizationTest.php | 20 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/resources/js/runtime-logs.js b/resources/js/runtime-logs.js index c5ff280f33..c067d8ac36 100644 --- a/resources/js/runtime-logs.js +++ b/resources/js/runtime-logs.js @@ -83,6 +83,23 @@ export function isFindShortcut(event) { return Boolean(event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey && event.key?.toLowerCase() === 'f'; } +const DEFAULT_LOG_FILTERS = { error: true, warning: true, debug: true, info: true }; + +/** Read saved level filters. Invalid saved values fall back to the defaults. */ +export function readLogFilters(saved) { + const filters = { ...DEFAULT_LOG_FILTERS }; + let parsed; + try { + parsed = JSON.parse(saved); + } catch { + return filters; + } + for (const level of Object.keys(filters)) { + if (typeof parsed?.[level] === 'boolean') filters[level] = parsed[level]; + } + return filters; +} + export function filterLogLines(lines, filters, query) { const needle = query.trim().toLowerCase(); return lines.filter((line) => filters[line.level] !== false && (!needle || line.lower.includes(needle))); @@ -170,7 +187,7 @@ export function initializeRuntimeLogsComponent() { scrollDebounce: null, destroyed: false, colorLogs: localStorage.getItem('coolify-color-logs') === 'true', - logFilters: JSON.parse(localStorage.getItem('coolify-log-filters')) || { error: true, warning: true, debug: true, info: true }, + logFilters: readLogFilters(localStorage.getItem('coolify-log-filters')), searchQuery: '', appliedQuery: '', matchCount: 0, diff --git a/tests/v4/Browser/RuntimeLogsVirtualizationTest.php b/tests/v4/Browser/RuntimeLogsVirtualizationTest.php index 8b849859e2..97073a034b 100644 --- a/tests/v4/Browser/RuntimeLogsVirtualizationTest.php +++ b/tests/v4/Browser/RuntimeLogsVirtualizationTest.php @@ -124,3 +124,23 @@ it('streams only new lines and appends them to the loaded log', function () { $page->screenshot(filename: 'runtime-logs-virtualized-stream'); }); + +it('falls back to the default level filters when the saved filters are invalid', function () { + $page = visit('/login') + ->fill('email', 'test@example.com') + ->fill('password', 'password') + ->click('Login') + ->assertSee('Dashboard'); + + $page->script('localStorage.setItem("coolify-log-filters", "{")'); + $page->navigate("/server/{$this->stack['server']->uuid}/sentinel/logs") + ->assertSee('log line 00001'); + + // A valid saved preference is still applied. + $page->script('localStorage.setItem("coolify-log-filters", JSON.stringify({ info: false }))'); + $page->navigate("/server/{$this->stack['server']->uuid}/sentinel/logs") + ->wait(1) + ->assertDontSee('log line 00001'); + + $page->screenshot(filename: 'runtime-logs-invalid-saved-filters'); +}); From 3b33e8a4ab753308dde26282ebe057e578d561d2 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:43:45 +0200 Subject: [PATCH 2/2] fix(domains): stop the DNS poll from flooding the high queue The Domains page polls every 2 seconds while a DNS check runs. Each poll parsed the compose file, and the parser queued one ServerFilesFromServerJob per volume. The jobs starved the DNS check jobs and deployments. - Skip the compose parse in pollDnsChecks and keep the known services. - Make ServerFilesFromServerJob unique per resource until it runs. Fixes #12098 --- app/Jobs/ServerFilesFromServerJob.php | 14 +++++++++++- app/Livewire/Project/Application/Domains.php | 18 ++++++++++----- tests/Feature/ApplicationDomainsTest.php | 23 ++++++++++++++++++++ 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/app/Jobs/ServerFilesFromServerJob.php b/app/Jobs/ServerFilesFromServerJob.php index 58455df2f4..73641131fe 100644 --- a/app/Jobs/ServerFilesFromServerJob.php +++ b/app/Jobs/ServerFilesFromServerJob.php @@ -7,20 +7,32 @@ use App\Models\ServiceApplication; use App\Models\ServiceDatabase; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; +use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -class ServerFilesFromServerJob implements ShouldBeEncrypted, ShouldQueue +class ServerFilesFromServerJob implements ShouldBeEncrypted, ShouldBeUniqueUntilProcessing, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + /** + * Only one sync per resource can wait in the queue. The compose parser dispatches + * this job once per volume on every parse, so without this the queue fills with copies. + */ + public int $uniqueFor = 600; + public function __construct(public ServiceApplication|ServiceDatabase|Application $resource) { $this->onQueue('high'); } + public function uniqueId(): string + { + return $this->resource::class.':'.$this->resource->id; + } + public function handle() { $this->resource->getFilesFromServer(isInit: true); diff --git a/app/Livewire/Project/Application/Domains.php b/app/Livewire/Project/Application/Domains.php index a0b54b7bb3..f1f897b855 100644 --- a/app/Livewire/Project/Application/Domains.php +++ b/app/Livewire/Project/Application/Domains.php @@ -178,11 +178,11 @@ class Domains extends Component $this->loadDomainState(); } - public function refreshDomains(): void + public function refreshDomains(bool $parseCompose = true): void { $editingRow = $this->editingIndex !== null ? ($this->domainRows[$this->editingIndex] ?? null) : null; - $this->loadDomainState(); + $this->loadDomainState($parseCompose); if ($editingRow !== null) { $index = collect($this->domainRows)->search(fn (array $row): bool => $row['url'] === $editingRow['url'] @@ -199,7 +199,9 @@ class Domains extends Component ->where('dns_status', 'checking') ->values(); - $this->refreshDomains(); + // The poll only refreshes DNS results. Parsing the compose file here would + // queue file sync jobs on every tick and starve the DNS check jobs. + $this->refreshDomains(parseCompose: false); foreach ($checkingRows as $checkingRow) { $row = collect($this->domainRows)->first(fn (array $row): bool => $row['url'] === $checkingRow['url'] @@ -258,7 +260,7 @@ class Domains extends Component $this->dispatch('success', 'HTTP to HTTPS redirect updated.'); } - public function loadDomainState(): void + public function loadDomainState(bool $parseCompose = true): void { $this->application->refresh(); $this->application->loadMissing(['destination.server', 'settings', 'additional_servers']); @@ -293,23 +295,27 @@ class Domains extends Component $this->serverIpConfigured = null; } - $this->composeServices = []; $pendingRedirect = $this->pendingRedirectService !== null ? ($this->serviceRedirects[$this->serviceRedirectWireKey($this->pendingRedirectService)] ?? null) : null; $this->serviceRedirects = []; - if ($this->isCompose) { + if (! $this->isCompose) { + $this->composeServices = []; + } elseif ($parseCompose) { try { $parsed = $this->application->parse() ?? []; } catch (\Throwable) { $parsed = []; } + $this->composeServices = []; $services = data_get($parsed, 'services', []); foreach ($services as $serviceName => $service) { if (! isDatabaseImage(data_get($service, 'image'))) { $this->composeServices[] = $serviceName; } } + } + if ($this->isCompose) { if ($this->newDomainService === null && count($this->composeServices) > 0) { $this->newDomainService = $this->composeServices[0]; } diff --git a/tests/Feature/ApplicationDomainsTest.php b/tests/Feature/ApplicationDomainsTest.php index 5ffea4298f..f468dcfc88 100644 --- a/tests/Feature/ApplicationDomainsTest.php +++ b/tests/Feature/ApplicationDomainsTest.php @@ -2,6 +2,7 @@ use App\Jobs\CheckDomainDnsJob; use App\Jobs\ConfigureDnsRecordJob; +use App\Jobs\ServerFilesFromServerJob; use App\Livewire\Project\Application\Domains; use App\Livewire\Project\Application\PreviewDomains; use App\Livewire\Project\Application\Previews; @@ -3656,3 +3657,25 @@ it('offers dns provider record creation for a public server ip', function () { ->toContain('Add with Cloudflare') ->not->toContain('The server has no public IP address; add the DNS record manually.'); }); + +it('does not queue file sync jobs when polling dns checks for compose applications', function () { + Queue::fake(); + + $this->application->update([ + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n volumes:\n - web-data:/data\n api:\n image: nginx:alpine\n volumes:\n - api-data:/data\n - api-conf:/etc/api\nvolumes:\n web-data:\n api-data:\n api-conf:\n", + 'docker_compose_domains' => json_encode([ + 'web' => ['domain' => 'https://compose-poll-test.invalid'], + ]), + 'fqdn' => null, + ]); + + $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]); + $queuedOnMount = Queue::pushed(ServerFilesFromServerJob::class)->count(); + + $component->call('pollDnsChecks')->call('pollDnsChecks')->call('pollDnsChecks'); + + expect($queuedOnMount)->toBe(1) + ->and(Queue::pushed(ServerFilesFromServerJob::class))->toHaveCount(1) + ->and($component->get('composeServices'))->toBe(['web', 'api']); +});