diff --git a/CHANGELOG.md b/CHANGELOG.md index fcdb7dbb1b..4288272018 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Fixed +- Fix **Copy logs** in the runtime and deployment logs on iPhone and iPad. iOS browsers accept a clipboard write only during the tap, and Coolify wrote the logs only after it loaded them from the server. The copy did not change the clipboard, but some browsers showed "Logs copied to clipboard." Coolify now starts the clipboard write during the tap and adds the logs when they load ([#9408](https://github.com/coollabsio/coolify/issues/9408)) - Open fewer SSH logins for the server connection check. The Docker and Docker Compose version checks now use the multiplexed SSH connection, so each check opens one new SSH login, not three. Each login makes a systemd-logind session on the server, and some systemd versions keep closed sessions in the `closing` state. After 8192 sessions the server refused new SSH logins with "Maximum number of sessions (8192) reached". To remove the stuck sessions, run `systemctl restart systemd-logind` on the server ([#9148](https://github.com/coollabsio/coolify/issues/9148)) - Fix the Docker install fallback on Ubuntu derivatives such as Pop!_OS and Linux Mint. `get.docker.com` does not support them, and the fallback used a Docker repository with the name of the distribution (`download.docker.com/linux/pop`), which does not exist, so the Docker install failed. The fallback now uses the Ubuntu repository with `UBUNTU_CODENAME` on these systems, and the Debian repository on Raspbian 13 (Trixie), because Docker has no Raspbian Trixie repository ([#9455](https://github.com/coollabsio/coolify/issues/9455)) - Ask for confirmation in a dialog before you change the name of a persistent volume of an application or a database. A new name does not rename the Docker volume: after the next restart, the resource uses a different volume, which Docker creates empty, and the data stays in the old volume. Before, Coolify saved the new name without a warning, so the data looked lost after the restart. The dialog tells you to copy the data into the new volume ([#8816](https://github.com/coollabsio/coolify/issues/8816)) diff --git a/app/Livewire/Project/Application/DeploymentNavbar.php b/app/Livewire/Project/Application/DeploymentNavbar.php index 654b571db3..f9e79a8c73 100644 --- a/app/Livewire/Project/Application/DeploymentNavbar.php +++ b/app/Livewire/Project/Application/DeploymentNavbar.php @@ -67,36 +67,6 @@ class DeploymentNavbar extends Component } } - public function copyLogsToClipboard(): string - { - $this->authorize('view', $this->application); - - $logs = json_decode($this->application_deployment_queue->logs, associative: true, flags: JSON_THROW_ON_ERROR); - - if (! $logs) { - return ''; - } - - $teamId = $this->application->team()?->id; - $hideDebugLines = is_null($teamId) || ! auth()->user()->isAdminOfTeam($teamId); - - $markdown = "# Deployment Logs\n\n"; - $markdown .= "```\n"; - - foreach ($logs as $log) { - if ($hideDebugLines && ! empty($log['hidden'])) { - continue; - } - if (isset($log['output'])) { - $markdown .= $log['output']."\n"; - } - } - - $markdown .= "```\n"; - - return $markdown; - } - public function cancel() { try { diff --git a/resources/js/app.js b/resources/js/app.js index 6831848b38..984293afb3 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,3 +1,4 @@ +import { copyTextToClipboard } from './clipboard.js'; import { initializeCopyButtonComponent } from './copy-button.js'; import { initializeNumberStepperComponent } from './number-stepper.js'; import { initializeRuntimeLogsComponent } from './runtime-logs.js'; @@ -31,6 +32,9 @@ document.addEventListener('livewire:navigated', loadCanvases); // Used by the modal-confirmation Blade component to call its submitAction. window.parseModalSubmitAction = parseSubmitAction; +// Used by the log viewers to copy logs that a Livewire call still loads. +window.copyTextToClipboard = copyTextToClipboard; + document.addEventListener('livewire:init', () => { registerLivewireRequestFailureHandler(window.Livewire); registerLivewireUpdateDedupe(window.Livewire); diff --git a/resources/js/clipboard.js b/resources/js/clipboard.js new file mode 100644 index 0000000000..a966e82fb3 --- /dev/null +++ b/resources/js/clipboard.js @@ -0,0 +1,20 @@ +// Copies text that can still be loading (for example logs from a Livewire call). +// WebKit (Safari and every iOS browser) accepts a clipboard write only while the +// click is still active, so an await before the write fails there. Giving +// ClipboardItem the pending Promise starts the write inside the click. +// https://webkit.org/blog/10855/async-clipboard-api/ +export async function copyTextToClipboard(text, clipboard = globalThis.navigator?.clipboard, Item = globalThis.ClipboardItem) { + const textPromise = Promise.resolve(text); + if (clipboard?.write && Item) { + try { + const blob = textPromise.then((value) => new Blob([value], { type: 'text/plain' })); + await clipboard.write([new Item({ 'text/plain': blob })]); + return; + } catch { + // Rethrows when the text failed to load. Otherwise try writeText for + // browsers that do not accept a Promise in ClipboardItem. + await textPromise; + } + } + await clipboard.writeText(await textPromise); +} diff --git a/resources/js/clipboard.test.js b/resources/js/clipboard.test.js new file mode 100644 index 0000000000..8fc85b5823 --- /dev/null +++ b/resources/js/clipboard.test.js @@ -0,0 +1,64 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { copyTextToClipboard } from './clipboard.js'; + +class FakeClipboardItem { + constructor(items) { + this.items = items; + } +} + +function fakeClipboard({ writeFails = false } = {}) { + const calls = []; + return { + calls, + write(items) { + calls.push('write'); + if (writeFails) { + return Promise.reject(new Error('NotAllowedError')); + } + return items[0].items['text/plain'].then((blob) => blob.text()).then((text) => { + this.copied = text; + }); + }, + writeText(text) { + calls.push('writeText'); + this.copied = text; + return Promise.resolve(); + }, + }; +} + +test('starts the clipboard write before the text has loaded', async () => { + const clipboard = fakeClipboard(); + let resolveText; + const text = new Promise((resolve) => (resolveText = resolve)); + + const copying = copyTextToClipboard(text, clipboard, FakeClipboardItem); + assert.deepEqual(clipboard.calls, ['write']); + + resolveText('line 1\nline 2'); + await copying; + assert.equal(clipboard.copied, 'line 1\nline 2'); +}); + +test('falls back to writeText when ClipboardItem is not available', async () => { + const clipboard = fakeClipboard(); + await copyTextToClipboard(Promise.resolve('logs'), clipboard, undefined); + assert.deepEqual(clipboard.calls, ['writeText']); + assert.equal(clipboard.copied, 'logs'); +}); + +test('falls back to writeText when write rejects', async () => { + const clipboard = fakeClipboard({ writeFails: true }); + await copyTextToClipboard('logs', clipboard, FakeClipboardItem); + assert.deepEqual(clipboard.calls, ['write', 'writeText']); + assert.equal(clipboard.copied, 'logs'); +}); + +test('rejects and does not retry when the text fails to load', async () => { + const clipboard = fakeClipboard(); + await assert.rejects(copyTextToClipboard(Promise.reject(new Error('server error')), clipboard, FakeClipboardItem), /server error/); + assert.deepEqual(clipboard.calls, ['write']); + assert.equal(clipboard.copied, undefined); +}); diff --git a/resources/views/livewire/project/application/deployment/show.blade.php b/resources/views/livewire/project/application/deployment/show.blade.php index cd21bcafd9..b0775d1ac1 100644 --- a/resources/views/livewire/project/application/deployment/show.blade.php +++ b/resources/views/livewire/project/application/deployment/show.blade.php @@ -199,14 +199,14 @@ return ''; } }, - async copyLogs() { - const content = await this.exportLogs(); - if (!content) return; - if (!navigator.clipboard?.writeText) { + copyLogs() { + if (!navigator.clipboard) { Livewire.dispatch('error', ['Clipboard is not available. Please use HTTPS or localhost.']); return; } - navigator.clipboard.writeText(content).then(() => { + // No await before the copy: iOS accepts the write only during the click. + const content = this.$wire.exportLogs(this.showTimestamps, this.searchQuery.trim()); + window.copyTextToClipboard(content).then(() => { Livewire.dispatch('success', ['Logs copied to clipboard.']); }).catch(() => { Livewire.dispatch('error', ['Failed to copy logs to clipboard.']); diff --git a/resources/views/livewire/project/shared/get-logs.blade.php b/resources/views/livewire/project/shared/get-logs.blade.php index da568d9d1d..348b0e37a3 100644 --- a/resources/views/livewire/project/shared/get-logs.blade.php +++ b/resources/views/livewire/project/shared/get-logs.blade.php @@ -199,19 +199,15 @@