diff --git a/resources/css/app.css b/resources/css/app.css
index 9423c24430..c963d109fe 100644
--- a/resources/css/app.css
+++ b/resources/css/app.css
@@ -3956,6 +3956,25 @@ html[data-theme="custom"] .logs-viewer-timestamp {
opacity: 1 !important;
}
+/* View and Export menus sit after the search field and the Lines control. */
+.runtime-log-menus {
+ display: flex;
+ order: 3;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.runtime-log-menu-label {
+ padding: 0.5rem 0.5rem 0.25rem;
+ font-size: 0.6875rem;
+ font-weight: 500;
+ color: #737373;
+}
+
+.dark .runtime-log-menu-label {
+ color: var(--color-fg-faint);
+}
+
.runtime-log-viewport {
min-height: 12rem;
border-radius: 0 0 8px 8px;
@@ -4139,6 +4158,43 @@ html[data-theme="custom"] .runtime-log-columns {
grid-column: 2;
}
+/* Text view: one compact, wrapped text line per log entry, like the old viewer. */
+.runtime-log-text-view {
+ padding-block: 0.5rem;
+}
+
+.runtime-log-viewport .runtime-log-text-view [data-log-line]:not(.hidden) {
+ display: flex;
+ gap: 0.75rem;
+ min-height: 0;
+ padding: 0 1rem;
+ border-bottom: 0;
+ cursor: text;
+ font-size: 0.75rem;
+ line-height: 1.5;
+}
+
+.runtime-log-viewport .runtime-log-text-view [data-log-line]::before,
+.runtime-log-viewport .runtime-log-text-view [data-log-line]::after {
+ display: none;
+}
+
+.runtime-log-viewport .runtime-log-text-view .logs-viewer-timestamp {
+ font-size: inherit;
+ line-height: inherit;
+}
+
+.runtime-log-viewport .runtime-log-text-view [data-line-text] {
+ flex: 1 1 auto;
+ overflow: visible;
+ overflow-wrap: anywhere;
+ white-space: pre-wrap;
+}
+
+.runtime-log-viewport .runtime-log-text-view .log-error [data-line-text] { color: #e55e73; }
+.runtime-log-viewport .runtime-log-text-view .log-warning [data-line-text] { color: #d79945; }
+.runtime-log-viewport .runtime-log-text-view .log-debug [data-line-text] { color: #929099; }
+
@container runtime-log-explorer (max-width: 650px) {
.runtime-log-columns,
.runtime-log-viewport [data-log-line]:not(.hidden) {
diff --git a/resources/js/runtime-logs.js b/resources/js/runtime-logs.js
index c067d8ac36..0dad173ada 100644
--- a/resources/js/runtime-logs.js
+++ b/resources/js/runtime-logs.js
@@ -13,6 +13,7 @@ import {
export const MAX_CLIENT_LOG_LINES = 50000;
export const STREAM_INTERVAL_MS = 2000;
const ESTIMATED_ROW_HEIGHT = 44;
+const ESTIMATED_TEXT_ROW_HEIGHT = 20;
const TIMESTAMP_PATTERN = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z) (.*)$/s;
@@ -187,6 +188,7 @@ export function initializeRuntimeLogsComponent() {
scrollDebounce: null,
destroyed: false,
colorLogs: localStorage.getItem('coolify-color-logs') === 'true',
+ textView: localStorage.getItem('coolify-log-text-view') === 'true',
logFilters: readLogFilters(localStorage.getItem('coolify-log-filters')),
searchQuery: '',
appliedQuery: '',
@@ -222,7 +224,7 @@ export function initializeRuntimeLogsComponent() {
virtualizer = new Virtualizer({
count: 0,
getScrollElement: () => this.$refs.viewport ?? null,
- estimateSize: () => ESTIMATED_ROW_HEIGHT,
+ estimateSize: () => (this.textView ? ESTIMATED_TEXT_ROW_HEIGHT : ESTIMATED_ROW_HEIGHT),
getItemKey: (index) => filtered[index]?.id ?? index,
overscan: 12,
// Keep the visible rows in place when old lines are trimmed from the top.
@@ -370,6 +372,7 @@ export function initializeRuntimeLogsComponent() {
},
toggleLogDetails(id, event) {
+ if (this.textView) return;
if (window.getSelection()?.toString()) return;
if (event.type === 'keydown' && !['Enter', ' '].includes(event.key)) return;
if (event.type === 'keydown') event.preventDefault();
@@ -377,7 +380,7 @@ export function initializeRuntimeLogsComponent() {
},
isLogExpanded(id) {
- return this.expandedLogs[id] === true;
+ return !this.textView && this.expandedLogs[id] === true;
},
toggleLogFilter(level) {
@@ -391,6 +394,18 @@ export function initializeRuntimeLogsComponent() {
localStorage.setItem('coolify-color-logs', this.colorLogs);
},
+ // Plain text lines without columns, row borders, or expandable details.
+ toggleTextView() {
+ this.textView = !this.textView;
+ localStorage.setItem('coolify-log-text-view', this.textView);
+ this.$nextTick(() => {
+ virtualizer?.measure();
+ this.$refs.list?.querySelectorAll('.runtime-log-virtual-row').forEach((el) => this.measureRow(el));
+ this.updateVirtualizer();
+ if (this.alwaysScroll) this.scrollToBottom();
+ });
+ },
+
downloadLogs() {
const timestamp = new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-');
downloadText(this.visibleText() + '\n', `${this.containerName}-logs-${timestamp}.txt`);
diff --git a/resources/views/components/skeleton/cards.blade.php b/resources/views/components/skeleton/cards.blade.php
new file mode 100644
index 0000000000..c88406fcf3
--- /dev/null
+++ b/resources/views/components/skeleton/cards.blade.php
@@ -0,0 +1,23 @@
+@props([
+ 'count' => 6,
+ 'label' => 'Loading',
+])
+
+{{-- Card grid skeleton: an icon tile, a title, and two description lines per card. --}}
+
class(['grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3']) }}>
+
{{ $label }}
+ @for ($i = 0; $i < (int) $count; $i++)
+
+ @endfor
+
diff --git a/resources/views/components/skeleton/form.blade.php b/resources/views/components/skeleton/form.blade.php
new file mode 100644
index 0000000000..56914de7a4
--- /dev/null
+++ b/resources/views/components/skeleton/form.blade.php
@@ -0,0 +1,15 @@
+@props([
+ 'fields' => 4,
+ 'label' => 'Loading',
+])
+
+{{-- Form skeleton: a label and an input per field, two columns on wide screens. --}}
+class(['grid gap-4 md:grid-cols-2']) }}>
+
{{ $label }}
+ @for ($i = 0; $i < (int) $fields; $i++)
+
+
+
+
+ @endfor
+
diff --git a/resources/views/components/skeleton/list.blade.php b/resources/views/components/skeleton/list.blade.php
new file mode 100644
index 0000000000..82695ef9d6
--- /dev/null
+++ b/resources/views/components/skeleton/list.blade.php
@@ -0,0 +1,25 @@
+@props([
+ 'rows' => 4,
+ 'label' => 'Loading',
+])
+
+@php
+ // Vary the title widths so rows read as content, not a solid block.
+ $widths = ['w-2/5', 'w-1/3', 'w-1/2', 'w-1/4'];
+@endphp
+
+{{-- List rows with a leading icon tile, a title, a meta line, and a trailing button. --}}
+class(['flex flex-col']) }}>
+
{{ $label }}
+ @for ($i = 0; $i < (int) $rows; $i++)
+
+ @endfor
+
diff --git a/resources/views/components/skeleton/logs.blade.php b/resources/views/components/skeleton/logs.blade.php
new file mode 100644
index 0000000000..4e201dcbc5
--- /dev/null
+++ b/resources/views/components/skeleton/logs.blade.php
@@ -0,0 +1,30 @@
+@php
+ // Vary the line widths so rows read as log output, not a solid block.
+ $widths = ['w-3/5', 'w-2/5', 'w-4/5', 'w-1/2', 'w-3/4', 'w-1/3', 'w-2/3', 'w-1/2'];
+@endphp
+
+{{-- Runtime log panel skeleton: header, toolbar, and log lines. --}}
+class(['runtime-log-shell overflow-hidden']) }}>
+
Loading containers
+
+
+
+
+
+ @for ($i = 0; $i < 4; $i++)
+
+ @endfor
+
+
+
+
+
+
+ @foreach ($widths as $width)
+
+
+
+
+ @endforeach
+
+
diff --git a/resources/views/components/skeleton/settings-sections.blade.php b/resources/views/components/skeleton/settings-sections.blade.php
new file mode 100644
index 0000000000..2c9c195dc1
--- /dev/null
+++ b/resources/views/components/skeleton/settings-sections.blade.php
@@ -0,0 +1,20 @@
+@props([
+ 'count' => 2,
+ 'label' => 'Loading',
+])
+
+{{-- Settings section skeleton: a title bar and a content block per section. --}}
+class(['flex flex-col gap-6']) }}>
+
{{ $label }}
+ @for ($i = 0; $i < (int) $count; $i++)
+
+ @endfor
+
diff --git a/resources/views/livewire/project/application/previews.blade.php b/resources/views/livewire/project/application/previews.blade.php
index 23edbf3966..0792e96aa3 100644
--- a/resources/views/livewire/project/application/previews.blade.php
+++ b/resources/views/livewire/project/application/previews.blade.php
@@ -27,8 +27,8 @@
-
-
+
+
diff --git a/resources/views/livewire/project/application/rollback.blade.php b/resources/views/livewire/project/application/rollback.blade.php
index e28f8701c0..c159af392c 100644
--- a/resources/views/livewire/project/application/rollback.blade.php
+++ b/resources/views/livewire/project/application/rollback.blade.php
@@ -90,10 +90,7 @@
-
-
- Loading available images…
-
+
diff --git a/resources/views/livewire/project/new/select.blade.php b/resources/views/livewire/project/new/select.blade.php
index 63386039e8..7722722582 100644
--- a/resources/views/livewire/project/new/select.blade.php
+++ b/resources/views/livewire/project/new/select.blade.php
@@ -92,8 +92,8 @@
-
-
+
+
-
+ class="application-settings-section-body is-flush mt-1 w-full scroll-mt-28" role="status">
+ Loading environment variables
+
@else
@php
diff --git a/resources/views/livewire/project/shared/get-logs.blade.php b/resources/views/livewire/project/shared/get-logs.blade.php
index 7b000c8a78..3f855abea3 100644
--- a/resources/views/livewire/project/shared/get-logs.blade.php
+++ b/resources/views/livewire/project/shared/get-logs.blade.php
@@ -42,13 +42,9 @@
@@ -276,9 +272,9 @@
{{-- Alpine renders only the visible rows. Livewire must not morph them. --}}
-
+
Time
Type
Message
@@ -288,7 +284,7 @@
-
@endif
-
-
+
+
-
@if (! $containersLoaded)
-
-
-
+
@elseif ($logsUnavailable)
@if ($servers->isEmpty())
@if (!$loaded)
-
-
+
+ Reading images from the Docker daemon
+
@elseif ($visibleImages->isEmpty())
diff --git a/resources/views/livewire/server/new/by-digital-ocean.blade.php b/resources/views/livewire/server/new/by-digital-ocean.blade.php
index 01a47fd0af..53325cfc73 100644
--- a/resources/views/livewire/server/new/by-digital-ocean.blade.php
+++ b/resources/views/livewire/server/new/by-digital-ocean.blade.php
@@ -17,9 +17,7 @@
@if ($loading_data)
-
-
-
+
@elseif ($provider_data_error)
-
-
-
+
@elseif ($provider_data_error)
-
-
-
+
@elseif ($provider_data_error)
@endif
-
-
+
+ Loading proxy configuration
+
+
@if ($proxySettings)
diff --git a/resources/views/livewire/server/proxy/certificates.blade.php b/resources/views/livewire/server/proxy/certificates.blade.php
index 8afcaa228c..43a0bfd091 100644
--- a/resources/views/livewire/server/proxy/certificates.blade.php
+++ b/resources/views/livewire/server/proxy/certificates.blade.php
@@ -17,9 +17,9 @@
-
-
+
+
diff --git a/resources/views/livewire/server/proxy/dynamic-configurations.blade.php b/resources/views/livewire/server/proxy/dynamic-configurations.blade.php
index 05c40d3eae..2bd50eb4e0 100644
--- a/resources/views/livewire/server/proxy/dynamic-configurations.blade.php
+++ b/resources/views/livewire/server/proxy/dynamic-configurations.blade.php
@@ -34,10 +34,8 @@
-
-
-
+
@if ($contents?->isNotEmpty())
@foreach ($contents as $fileName => $value)
diff --git a/resources/views/livewire/subscription/index.blade.php b/resources/views/livewire/subscription/index.blade.php
index 7fb0f67a2c..1595e442e5 100644
--- a/resources/views/livewire/subscription/index.blade.php
+++ b/resources/views/livewire/subscription/index.blade.php
@@ -8,8 +8,8 @@
@if (auth()->user()->isAdminFromSession())
@if ($loading)
-
-
+
+
@else
@if ($isUnpaid)
diff --git a/tests/Feature/EnvironmentVariableAsyncLoadTest.php b/tests/Feature/EnvironmentVariableAsyncLoadTest.php
index d98313a9f8..bed7db1094 100644
--- a/tests/Feature/EnvironmentVariableAsyncLoadTest.php
+++ b/tests/Feature/EnvironmentVariableAsyncLoadTest.php
@@ -68,10 +68,10 @@ it('loads environment variables when loadEnvironmentVariables is called', functi
]);
$component = Livewire::test(All::class, ['resource' => $application])
- ->assertSee('Loading environment variables...')
+ ->assertSee('Loading environment variables')
->call('loadEnvironmentVariables')
->assertSet('readyToLoad', true)
- ->assertDontSeeText('Loading environment variables...')
+ ->assertDontSeeText('Loading environment variables')
->assertSee('API_KEY');
expect($component->instance()->environmentVariables->pluck('key')->all())
diff --git a/tests/Feature/Server/NewServerProviderTokenUrlTest.php b/tests/Feature/Server/NewServerProviderTokenUrlTest.php
index 76effcceb7..3ac21bcfea 100644
--- a/tests/Feature/Server/NewServerProviderTokenUrlTest.php
+++ b/tests/Feature/Server/NewServerProviderTokenUrlTest.php
@@ -119,14 +119,14 @@ test('provider token pages defer provider api data loading until wire init', fun
->assertSet('loading_data', true)
->assertSee($loadingText)
->assertSee($wireInitCall, false)
- ->assertSee('loading-indicator shrink-0 animate-spin', false)
+ ->assertSee('role="status"', false)
->assertDontSee('border-b-2 border-primary', false);
Http::assertNothingSent();
})->with([
- 'hetzner' => [ByHetzner::class, 'hetzner', 'hetzner', 'Loading Hetzner data...', 'wire:init="loadHetznerData"'],
- 'vultr' => [ByVultr::class, 'vultr', 'vultr', 'Loading Vultr data...', 'wire:init="loadVultrData"'],
- 'digital-ocean' => [ByDigitalOcean::class, 'digitalocean', 'digital-ocean', 'Loading DigitalOcean data...', 'wire:init="loadDigitalOceanData"'],
+ 'hetzner' => [ByHetzner::class, 'hetzner', 'hetzner', 'Loading Hetzner data', 'wire:init="loadHetznerData"'],
+ 'vultr' => [ByVultr::class, 'vultr', 'vultr', 'Loading Vultr data', 'wire:init="loadVultrData"'],
+ 'digital-ocean' => [ByDigitalOcean::class, 'digitalocean', 'digital-ocean', 'Loading DigitalOcean data', 'wire:init="loadDigitalOceanData"'],
]);
test('provider token pages render api error details when a selected token is invalid', function (string $component, string $provider, string $type, string $loadMethod, string $providerName, string $apiMessage, array $responseBody) {
diff --git a/tests/v4/Browser/RuntimeLogsVirtualizationTest.php b/tests/v4/Browser/RuntimeLogsVirtualizationTest.php
index 97073a034b..745281b980 100644
--- a/tests/v4/Browser/RuntimeLogsVirtualizationTest.php
+++ b/tests/v4/Browser/RuntimeLogsVirtualizationTest.php
@@ -144,3 +144,41 @@ it('falls back to the default level filters when the saved filters are invalid',
$page->screenshot(filename: 'runtime-logs-invalid-saved-filters');
});
+
+it('shows compact text lines in text view and remembers the choice', function () {
+ $page = visit('/login')
+ ->fill('email', 'test@example.com')
+ ->fill('password', 'password')
+ ->click('Login')
+ ->assertSee('Dashboard')
+ ->navigate("/server/{$this->stack['server']->uuid}/sentinel/logs");
+
+ $page->assertSee('log line 00001')
+ ->click('[title="View Options"]')
+ ->click('button:visible:has-text("Text view")')
+ ->wait(0.3);
+
+ $page->screenshot(filename: 'runtime-logs-view-menu');
+
+ expect($page->script('document.querySelector("[data-log-line]").getBoundingClientRect().height'))->toBeLessThan(24);
+
+ // Rows rendered before the switch are measured again, so no gaps stay between lines.
+ expect($page->script('(() => {
+ const rows = [...document.querySelectorAll(".runtime-log-virtual-row")].sort((a, b) => a.dataset.index - b.dataset.index);
+ return rows.slice(1).every((row, i) => row.getBoundingClientRect().top - rows[i].getBoundingClientRect().bottom < 1);
+ })()'))->toBeTrue();
+
+ // Rows do not open details in text view.
+ $page->click('[title="View Options"]')
+ ->click('log line 00001')
+ ->wait(0.3);
+
+ expect($page->script('document.querySelectorAll(".runtime-log-detail").length'))->toBe(0);
+
+ $page->screenshot(filename: 'runtime-logs-text-view');
+
+ $page->navigate("/server/{$this->stack['server']->uuid}/sentinel/logs")
+ ->assertSee('log line 00001');
+
+ expect($page->script('document.getElementById("logs").classList.contains("runtime-log-text-view")'))->toBeTrue();
+});