fix(logs): start the log copy during the click so it works on iOS

Closes #9408

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras BacsaiandClaude Opus 5.5 committed 2026-11-03 20:41:03 +01:00
1 parent 5431d4954d
commit 1ff99b0dc8
8 files changed
+102 -63

No files matched your search

+1
View File
@@ -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))
@@ -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 {
+4
View File
@@ -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);
+20
View File
@@ -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);
}
+64
View File
@@ -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);
});
@@ -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.']);
@@ -199,19 +199,15 @@
<button type="button" role="menuitem" class="listbox-option"
x-on:click="
open = false;
$wire.copyLogs().then(logs => {
logs = logs.replace(/^([^\t\n]+)\t/gm, '$1 | ');
if (!navigator.clipboard?.writeText) {
Livewire.dispatch('error', ['Clipboard is not available. Please use HTTPS or localhost.']);
return;
}
navigator.clipboard.writeText(logs).then(() => {
Livewire.dispatch('success', ['Logs copied to clipboard.']);
}).catch(() => {
Livewire.dispatch('error', ['Failed to copy logs to clipboard.']);
});
if (!navigator.clipboard) {
Livewire.dispatch('error', ['Clipboard is not available. Please use HTTPS or localhost.']);
return;
}
const logs = $wire.copyLogs().then(logs => logs.replace(/^([^\t\n]+)\t/gm, '$1 | '));
window.copyTextToClipboard(logs).then(() => {
Livewire.dispatch('success', ['Logs copied to clipboard.']);
}).catch(() => {
Livewire.dispatch('error', ['Failed to prepare logs for clipboard.']);
Livewire.dispatch('error', ['Failed to copy logs to clipboard.']);
});
">
<span class="flex-1 text-left">Copy logs</span>
@@ -1,7 +1,6 @@
<?php
use App\Enums\ApplicationDeploymentStatus;
use App\Livewire\Project\Application\DeploymentNavbar;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Environment;
@@ -12,7 +11,6 @@ use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
@@ -72,17 +70,3 @@ test('deployment log lines show debug output to a resource team admin whose sess
expect($lines)->toContain('hidden-debug-line');
});
test('copied deployment logs hide debug output from a member after switching to a team they own', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$component = Livewire::test(DeploymentNavbar::class, ['application_deployment_queue' => $this->deployment]);
session(['currentTeam' => $this->ownTeam]);
$markdown = $component->instance()->copyLogsToClipboard();
expect($markdown)->toContain('visible-line')
->not->toContain('hidden-debug-line');
});