feat(logs): add text view, group log toolbar into menus, and use skeleton loaders

- Runtime logs: add a compact text view, move display options and log level filters into a View menu, move copy and download into an Export menu, and remove the extra top margin.
- Replace spinner loading cards with skeletons on the logs, dynamic configurations, proxy, TLS certificates, Docker images, environment variables, preview deployments, rollback, subscription, cloud provider, and new resource pages.
This commit is contained in:
Andras Bacsai
2026-10-12 14:55:37 +02:00
parent 7837a07ba2
commit c54902f776
24 changed files with 397 additions and 190 deletions
+56
View File
@@ -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) {
+17 -2
View File
@@ -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`);
@@ -0,0 +1,23 @@
@props([
'count' => 6,
'label' => 'Loading',
])
{{-- Card grid skeleton: an icon tile, a title, and two description lines per card. --}}
<div role="status" {{ $attributes->class(['grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3']) }}>
<span class="sr-only">{{ $label }}</span>
@for ($i = 0; $i < (int) $count; $i++)
<div aria-hidden="true"
class="flex min-h-48 flex-col rounded-xl border border-neutral-200 p-4 dark:border-white/[0.08]">
<div class="flex items-center gap-3">
<x-skeleton class="size-11 shrink-0 rounded-xl" />
<div class="flex flex-1 flex-col gap-2">
<x-skeleton class="h-3 w-1/2 rounded-full" />
<x-skeleton class="h-2.5 w-1/4 rounded-full" />
</div>
</div>
<x-skeleton class="mt-4 h-2.5 w-full rounded-full" />
<x-skeleton class="mt-2 h-2.5 w-3/4 rounded-full" />
</div>
@endfor
</div>
@@ -0,0 +1,15 @@
@props([
'fields' => 4,
'label' => 'Loading',
])
{{-- Form skeleton: a label and an input per field, two columns on wide screens. --}}
<div role="status" {{ $attributes->class(['grid gap-4 md:grid-cols-2']) }}>
<span class="sr-only">{{ $label }}</span>
@for ($i = 0; $i < (int) $fields; $i++)
<div aria-hidden="true" class="flex flex-col gap-2">
<x-skeleton class="h-2.5 {{ $i % 2 === 0 ? 'w-24' : 'w-32' }} rounded-full" />
<x-skeleton class="h-9 w-full rounded-lg" />
</div>
@endfor
</div>
@@ -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. --}}
<div role="status" {{ $attributes->class(['flex flex-col']) }}>
<span class="sr-only">{{ $label }}</span>
@for ($i = 0; $i < (int) $rows; $i++)
<div aria-hidden="true"
class="flex items-center gap-3 border-b border-neutral-200 px-4 py-3.5 last:border-b-0 dark:border-white/[0.07]">
<x-skeleton class="size-9 shrink-0 rounded-lg" />
<div class="flex min-w-0 flex-1 flex-col gap-2">
<x-skeleton class="h-3 {{ $widths[$i % count($widths)] }} rounded-full" />
<x-skeleton class="h-2.5 w-32 rounded-full" />
</div>
<x-skeleton class="hidden h-8 w-24 shrink-0 rounded-lg sm:block" />
</div>
@endfor
</div>
@@ -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. --}}
<div role="status" {{ $attributes->class(['runtime-log-shell overflow-hidden']) }}>
<span class="sr-only">Loading containers</span>
<div class="flex min-h-11 items-center gap-2 px-3.5">
<x-skeleton class="size-4 rounded" />
<x-skeleton class="h-3 w-48 rounded-full" />
</div>
<div class="flex items-center gap-2 border-y border-neutral-200 px-3.5 py-2.5 dark:border-white/[0.07]">
@for ($i = 0; $i < 4; $i++)
<x-skeleton class="size-6" />
@endfor
<x-skeleton class="ml-auto hidden h-7 w-56 rounded-lg sm:block" />
<x-skeleton class="hidden h-7 w-32 rounded-lg sm:block" />
<x-skeleton class="h-7 w-20 rounded-lg" />
<x-skeleton class="h-7 w-20 rounded-lg" />
</div>
<div class="flex flex-col gap-2.5 px-4 py-4">
@foreach ($widths as $width)
<div class="flex items-center gap-3">
<x-skeleton class="hidden h-3 w-36 shrink-0 rounded-full sm:block" />
<x-skeleton class="h-3 {{ $width }} rounded-full" />
</div>
@endforeach
</div>
</div>
@@ -0,0 +1,20 @@
@props([
'count' => 2,
'label' => 'Loading',
])
{{-- Settings section skeleton: a title bar and a content block per section. --}}
<div role="status" {{ $attributes->class(['flex flex-col gap-6']) }}>
<span class="sr-only">{{ $label }}</span>
@for ($i = 0; $i < (int) $count; $i++)
<section class="application-settings-section" aria-hidden="true">
<header>
<x-skeleton class="my-1 h-3 {{ $i % 2 === 0 ? 'w-40' : 'w-28' }} rounded-full" />
<x-skeleton class="h-7 w-20 rounded-lg" />
</header>
<div class="application-settings-section-body">
<x-skeleton class="h-40 w-full rounded-lg" />
</div>
</section>
@endfor
</div>
@@ -27,8 +27,8 @@
</x-forms.button>
</x-slot:headerActions>
<div class="flex min-h-48 items-center justify-center" wire:loading wire:target="load_prs">
<x-loading text="Loading pull requests…" />
<div class="-m-4" wire:loading wire:target="load_prs">
<x-skeleton.list :rows="3" label="Loading pull requests" />
</div>
<div class="-m-4" wire:loading.remove wire:target="load_prs">
@@ -90,10 +90,7 @@
</div>
<div class="w-full" wire:target="loadImages" wire:loading>
<div class="flex items-center justify-center gap-2 px-4 py-10 text-[13px] text-neutral-500 dark:text-fg-dim">
<x-loading class="size-4" />
Loading available images…
</div>
<x-skeleton.list :rows="3" label="Loading available images" />
</div>
</x-application.settings-section>
</div>
@@ -92,8 +92,8 @@
</div>
</x-application.settings-section>
<div x-show="loading" class="flex items-center justify-center py-8">
<x-loading text="Loading resources..." />
<div x-show="loading" class="mt-6">
<x-skeleton.cards :count="6" label="Loading resources" />
</div>
<div x-show="!loading" class="mt-6 flex flex-col gap-6">
<section
@@ -181,9 +181,9 @@
@if ($view === 'normal')
@if (! $readyToLoad)
<div id="environment-table-section"
class="application-settings-section-body mt-1 flex min-h-40 w-full scroll-mt-28 items-center justify-center"
wire:loading.class="opacity-100" wire:target="loadEnvironmentVariables">
<x-loading text="Loading environment variables..." />
class="application-settings-section-body is-flush mt-1 w-full scroll-mt-28" role="status">
<span class="sr-only">Loading environment variables</span>
<x-skeleton.table flush :rows="6" />
</div>
@else
@php
@@ -42,13 +42,9 @@
<div class="runtime-log-toolbar logs-viewer-toolbar">
<div class="logs-viewer-toolbar-controls">
<div class="logs-viewer-actions">
<button x-on:click="refresh()" title="Refresh Logs" {{ $streamLogs ? 'disabled' : '' }}
class="runtime-log-icon-button order-8">
<x-reicon name="refresh" class="size-3.5" />
</button>
<button wire:click="toggleStreamLogs"
title="{{ $streamLogs ? 'Stop Streaming' : 'Stream Logs' }}"
class="runtime-log-icon-button order-9 {{ $streamLogs ? 'runtime-log-icon-button-active' : '' }}">
class="runtime-log-icon-button {{ $streamLogs ? 'runtime-log-icon-button-active' : '' }}">
@if ($streamLogs)
{{-- Pause icon --}}
<svg class="size-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"
@@ -63,156 +59,29 @@
</svg>
@endif
</button>
<button
x-on:click="
$wire.copyLogs().then(logs => {
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.']);
});
}).catch(() => {
Livewire.dispatch('error', ['Failed to prepare logs for clipboard.']);
});
"
title="Copy Logs"
class="runtime-log-icon-button order-6">
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H9.75" />
</svg>
<button x-on:click="refresh()" title="Refresh Logs" {{ $streamLogs ? 'disabled' : '' }}
class="runtime-log-icon-button">
<x-reicon name="refresh" class="size-3.5" />
</button>
<div x-data="{ downloadMenuOpen: false, downloadingAllLogs: false }" class="relative order-7 shrink-0">
<button x-on:click="downloadMenuOpen = !downloadMenuOpen" title="Download Logs"
class="runtime-log-icon-button">
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
</button>
<div x-show="downloadMenuOpen" x-on:click.away="downloadMenuOpen = false"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="transform opacity-0 scale-95"
x-transition:enter-end="transform opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="transform opacity-100 scale-100"
x-transition:leave-end="transform opacity-0 scale-95"
class="runtime-log-menu listbox-panel left-auto! right-0! z-[90]! min-w-52!">
<div>
<button x-on:click="downloadLogs(); downloadMenuOpen = false"
class="listbox-option">
Download displayed logs
</button>
<button x-on:click="
downloadingAllLogs = true;
$wire.downloadAllLogs().then(logs => {
if (!logs) return;
const blob = new Blob([logs], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const timestamp = new Date().toISOString().slice(0,19).replace(/[T:]/g, '-');
a.download = containerName + '-all-logs-' + timestamp + '.txt';
a.click();
URL.revokeObjectURL(url);
Livewire.dispatch('success', ['All logs downloaded.']);
}).finally(() => {
downloadingAllLogs = false;
downloadMenuOpen = false;
});
"
:disabled="downloadingAllLogs"
:class="{ 'opacity-50 cursor-not-allowed': downloadingAllLogs }"
class="listbox-option">
<span x-show="!downloadingAllLogs">Download all logs</span>
<span x-show="downloadingAllLogs" class="flex items-center gap-2">
<svg class="w-4 h-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Downloading...
</span>
</button>
</div>
</div>
</div>
<button wire:click="toggleTimestamps" title="Toggle Timestamps"
class="runtime-log-icon-button order-1 {{ $showTimeStamps ? 'runtime-log-icon-button-active' : '' }}">
<svg class="size-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none"
stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
</button>
<button title="Toggle Log Colors" x-on:click="toggleColorLogs"
:class="colorLogs ? 'runtime-log-icon-button-active' : ''"
class="runtime-log-icon-button order-3">
<svg class="size-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none"
stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M9.53 16.122a3 3 0 0 0-5.78 1.128 2.25 2.25 0 0 1-2.4 2.245 4.5 4.5 0 0 0 8.4-2.245c0-.399-.078-.78-.22-1.128Zm0 0a15.998 15.998 0 0 0 3.388-1.62m-5.043-.025a15.994 15.994 0 0 1 1.622-3.395m3.42 3.42a15.995 15.995 0 0 0 4.764-4.648l3.876-5.814a1.151 1.151 0 0 0-1.597-1.597L14.146 6.32a15.996 15.996 0 0 0-4.649 4.763m3.42 3.42a6.776 6.776 0 0 0-3.42-3.42" />
</svg>
</button>
<x-table.dropdown panel-class="runtime-log-menu min-w-40!">
<x-slot:trigger><button type="button" title="Filter Log Levels"
:class="Object.values(logFilters).some(v => !v) ? 'runtime-log-icon-button-active' : ''"
class="runtime-log-icon-button" aria-haspopup="listbox" :aria-expanded="open">
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 0 1-.659 1.591l-5.432 5.432a2.25 2.25 0 0 0-.659 1.591v2.927a2.25 2.25 0 0 1-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 0 0-.659-1.591L3.659 7.409A2.25 2.25 0 0 1 3 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0 1 12 3Z" />
</svg>
</button></x-slot:trigger>
<div>
<button type="button" class="listbox-option" x-on:click="toggleLogFilter('error')">
<span class="w-2.5 h-2.5 rounded-full bg-red-500"></span>
<span class="flex-1 text-left">Error</span>
<span x-show="logFilters.error">✓</span>
</button>
<button type="button" class="listbox-option" x-on:click="toggleLogFilter('warning')">
<span class="w-2.5 h-2.5 rounded-full bg-yellow-500"></span>
<span class="flex-1 text-left">Warning</span>
<span x-show="logFilters.warning">✓</span>
</button>
<button type="button" class="listbox-option" x-on:click="toggleLogFilter('debug')">
<span class="w-2.5 h-2.5 rounded-full bg-purple-500"></span>
<span class="flex-1 text-left">Debug</span>
<span x-show="logFilters.debug">✓</span>
</button>
<button type="button" class="listbox-option" x-on:click="toggleLogFilter('info')">
<span class="w-2.5 h-2.5 rounded-full bg-blue-500"></span>
<span class="flex-1 text-left">Info</span>
<span x-show="logFilters.info">✓</span>
</button>
</div>
</x-table.dropdown>
<button title="Follow Logs" :class="alwaysScroll ? 'runtime-log-icon-button-active' : ''"
x-on:click="toggleScroll"
class="runtime-log-icon-button order-2">
class="runtime-log-icon-button">
<svg class="size-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M12 5v14m4-4l-4 4m-4-4l4 4" />
</svg>
</button>
<button title="Fullscreen" x-show="!fullscreen" x-on:click="makeFullscreen"
class="runtime-log-icon-button order-5">
class="runtime-log-icon-button">
<svg class="size-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<g fill="none">
<path
d="M24 0v24H0V0h24ZM12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035c-.01-.004-.019-.001-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427c-.002-.01-.009-.017-.017-.018Zm.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093c.012.004.023 0 .029-.008l.004-.014l-.034-.614c-.003-.012-.01-.02-.02-.022Zm-.715.002a.023.023 0 0 0-.027.006l-.006.014l-.034.614c0 .012.007.02.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01l-.184-.092Z" />
<path fill="currentColor"
d="M9.793 12.793a1 1 0 0 1 1.497 1.32l-.083.094L6.414 19H9a1 1 0 0 1 .117 1.993L9 21H4a1 1 0 0 1-.993-.883L3 20v-5a1 1 0 0 1 1.993-.117L5 15v2.586l4.793-4.793ZM20 3a1 1 0 0 1 .993.883L21 4v5a1 1 0 0 1-1.993.117L19 9V6.414l-4.793 4.793a1 1 0 0 1-1.497-1.32l.083-.094L17.586 5H15a1 1 0 0 1-.117-1.993L15 3h5Z" />
</g>
</svg>
</button>
<button title="Minimize" x-show="fullscreen" x-on:click="makeFullscreen"
class="runtime-log-icon-button order-5">
class="runtime-log-icon-button">
<svg class="size-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M6 14h4m0 0v4m0-4l-6 6m14-10h-4m0 0V6m0 4l6-6" />
@@ -254,6 +123,133 @@
<x-reicon name="x" class="size-3" />
</button>
</div>
<div class="runtime-log-menus">
{{-- Display settings and level filters --}}
<x-table.dropdown panel-class="runtime-log-menu min-w-48! max-h-none!" role="menu">
<x-slot:trigger><button type="button" title="View Options"
:class="Object.values(logFilters).some(v => !v) ? 'button-highlighted' : ''"
class="button" aria-haspopup="menu" :aria-expanded="open">
<svg class="size-3.5 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75" />
</svg>
<span>View</span>
<x-reicon name="chevron-down" class="size-3 transition-transform" ::class="open ? 'rotate-180' : ''" />
</button></x-slot:trigger>
<div>
<p class="runtime-log-menu-label">Display</p>
<button type="button" class="listbox-option" role="menuitemcheckbox"
:aria-checked="$wire.showTimeStamps" wire:click="toggleTimestamps">
<span class="flex-1 text-left">Timestamps</span>
<span x-show="$wire.showTimeStamps">✓</span>
</button>
<button type="button" class="listbox-option" role="menuitemcheckbox"
:aria-checked="textView" x-on:click="toggleTextView">
<span class="flex-1 text-left">Text view</span>
<span x-show="textView">✓</span>
</button>
<button type="button" class="listbox-option" role="menuitemcheckbox"
:aria-checked="colorLogs" x-on:click="toggleColorLogs">
<span class="flex-1 text-left">Color by level</span>
<span x-show="colorLogs">✓</span>
</button>
<p class="runtime-log-menu-label">Log levels</p>
<button type="button" class="listbox-option" role="menuitemcheckbox"
:aria-checked="logFilters.error" x-on:click="toggleLogFilter('error')">
<span class="w-2.5 h-2.5 rounded-full bg-red-500"></span>
<span class="flex-1 text-left">Error</span>
<span x-show="logFilters.error">✓</span>
</button>
<button type="button" class="listbox-option" role="menuitemcheckbox"
:aria-checked="logFilters.warning" x-on:click="toggleLogFilter('warning')">
<span class="w-2.5 h-2.5 rounded-full bg-yellow-500"></span>
<span class="flex-1 text-left">Warning</span>
<span x-show="logFilters.warning">✓</span>
</button>
<button type="button" class="listbox-option" role="menuitemcheckbox"
:aria-checked="logFilters.debug" x-on:click="toggleLogFilter('debug')">
<span class="w-2.5 h-2.5 rounded-full bg-purple-500"></span>
<span class="flex-1 text-left">Debug</span>
<span x-show="logFilters.debug">✓</span>
</button>
<button type="button" class="listbox-option" role="menuitemcheckbox"
:aria-checked="logFilters.info" x-on:click="toggleLogFilter('info')">
<span class="w-2.5 h-2.5 rounded-full bg-blue-500"></span>
<span class="flex-1 text-left">Info</span>
<span x-show="logFilters.info">✓</span>
</button>
</div>
</x-table.dropdown>
{{-- Copy and download --}}
<x-table.dropdown panel-class="runtime-log-menu min-w-52!" role="menu">
<x-slot:trigger><button type="button" title="Copy or Download Logs"
class="button" aria-haspopup="menu" :aria-expanded="open">
<svg class="size-3.5 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
<span>Export</span>
<x-reicon name="chevron-down" class="size-3 transition-transform" ::class="open ? 'rotate-180' : ''" />
</button></x-slot:trigger>
<div x-data="{ downloadingAllLogs: false }">
<button type="button" role="menuitem" class="listbox-option"
x-on:click="
open = false;
$wire.copyLogs().then(logs => {
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.']);
});
}).catch(() => {
Livewire.dispatch('error', ['Failed to prepare logs for clipboard.']);
});
">
<span class="flex-1 text-left">Copy logs</span>
</button>
<button type="button" role="menuitem" class="listbox-option"
x-on:click="downloadLogs(); open = false">
<span class="flex-1 text-left">Download displayed logs</span>
</button>
<button type="button" role="menuitem" x-on:click="
downloadingAllLogs = true;
$wire.downloadAllLogs().then(logs => {
if (!logs) return;
const blob = new Blob([logs], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const timestamp = new Date().toISOString().slice(0,19).replace(/[T:]/g, '-');
a.download = containerName + '-all-logs-' + timestamp + '.txt';
a.click();
URL.revokeObjectURL(url);
Livewire.dispatch('success', ['All logs downloaded.']);
}).finally(() => {
downloadingAllLogs = false;
open = false;
});
"
:disabled="downloadingAllLogs"
:class="{ 'opacity-50 cursor-not-allowed': downloadingAllLogs }"
class="listbox-option">
<span x-show="!downloadingAllLogs" class="flex-1 text-left">Download all logs</span>
<span x-show="downloadingAllLogs" class="flex items-center gap-2">
<svg class="w-4 h-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Downloading...
</span>
</button>
</div>
</x-table.dropdown>
</div>
</div>
</div>
</div>
@@ -276,9 +272,9 @@
</div>
{{-- Alpine renders only the visible rows. Livewire must not morph them. --}}
<div id="logs" wire:ignore x-cloak x-show="lineCount > 0"
:class="{ 'runtime-log-without-time': !$wire.showTimeStamps }"
:class="{ 'runtime-log-without-time': !$wire.showTimeStamps, 'runtime-log-text-view': textView }"
class="font-logs max-w-full cursor-default text-[11px] leading-relaxed sm:text-xs">
<div x-ref="columns" class="runtime-log-columns" aria-hidden="true">
<div x-ref="columns" x-show="!textView" class="runtime-log-columns" aria-hidden="true">
<span x-show="$wire.showTimeStamps">Time</span>
<span>Type</span>
<span>Message</span>
@@ -288,7 +284,7 @@
<div class="runtime-log-virtual-row" :data-index="row.index"
:style="{ transform: 'translateY(' + (row.start - scrollMargin) + 'px)' }"
x-init="$nextTick(() => measureRow($el))">
<div data-log-line role="button" tabindex="0" :data-log-level="row.line.level"
<div data-log-line :role="textView ? null : 'button'" :tabindex="textView ? null : 0" :data-log-level="row.line.level"
:class="colorLogs ? 'log-' + row.line.level : ''"
:aria-expanded="isLogExpanded(row.line.id)"
x-on:click="toggleLogDetails(row.line.id, $event)"
@@ -36,17 +36,14 @@
<div class="min-w-0">
@endif
<div wire:loading.flex wire:target="loadAllContainers"
class="loading-state-card mt-4 hidden min-h-40 w-full items-center justify-center lg:mt-3">
<x-loading text="Loading containers" />
<div wire:loading.block wire:target="loadAllContainers" class="hidden w-full">
<x-skeleton.logs />
</div>
<div class="mt-4 w-full lg:mt-3" x-init="if (! $wire.containersLoaded) { $wire.loadAllContainers() }"
<div class="w-full" x-init="if (! $wire.containersLoaded) { $wire.loadAllContainers() }"
wire:loading.remove wire:target="loadAllContainers">
@if (! $containersLoaded)
<div class="loading-state-card flex min-h-40 w-full items-center justify-center">
<x-loading text="Loading containers" />
</div>
<x-skeleton.logs />
@elseif ($logsUnavailable)
@if ($servers->isEmpty())
<x-empty size="lg" title="Runtime logs unavailable"
@@ -35,8 +35,9 @@
</div>
@if (!$loaded)
<div class="p-6">
<x-loading text="Reading images from the Docker daemon" />
<div role="status">
<span class="sr-only">Reading images from the Docker daemon</span>
<x-skeleton.table flush :rows="6" />
</div>
@elseif ($visibleImages->isEmpty())
<div class="p-6">
@@ -17,9 +17,7 @@
@if ($loading_data)
<x-application.settings-section title="Loading DigitalOcean"
description="Fetching regions, sizes, images, and account resources.">
<div class="flex min-h-40 items-center justify-center">
<x-loading text="Loading DigitalOcean data..." />
</div>
<x-skeleton.form :fields="6" label="Loading DigitalOcean data" />
</x-application.settings-section>
@elseif ($provider_data_error)
<x-application.settings-section title="Unable to load DigitalOcean"
@@ -17,9 +17,7 @@
@if ($loading_data)
<x-application.settings-section title="Loading Hetzner"
description="Fetching locations, server types, images, and account resources.">
<div class="flex min-h-40 items-center justify-center">
<x-loading text="Loading Hetzner data..." />
</div>
<x-skeleton.form :fields="6" label="Loading Hetzner data" />
</x-application.settings-section>
@elseif ($provider_data_error)
<x-application.settings-section title="Unable to load Hetzner"
@@ -17,9 +17,7 @@
@if ($loading_data)
<x-application.settings-section title="Loading Vultr"
description="Fetching regions, plans, operating systems, and account resources.">
<div class="flex min-h-40 items-center justify-center">
<x-loading text="Loading Vultr data..." />
</div>
<x-skeleton.form :fields="6" label="Loading Vultr data" />
</x-application.settings-section>
@elseif ($provider_data_error)
<x-application.settings-section title="Unable to load Vultr"
@@ -143,9 +143,11 @@
<x-server.caddy-image-outdated-callout :image="$this->outdatedCaddyImage" />
@endif
<div wire:loading.flex wire:target="loadProxyConfiguration"
class="min-h-32 items-center justify-center">
<x-loading text="Loading proxy configuration…" />
<div wire:loading.flex wire:target="loadProxyConfiguration" role="status"
class="mt-4 hidden flex-col gap-2">
<span class="sr-only">Loading proxy configuration</span>
<x-skeleton class="h-2.5 w-56 rounded-full" />
<x-skeleton class="h-96 w-full rounded-lg" />
</div>
@if ($proxySettings)
@@ -17,9 +17,9 @@
</x-forms.button>
</x-slot:actions>
<div wire:loading.flex wire:target="loadTraefikCertificates"
class="min-h-24 items-center justify-center">
<x-loading text="Loading TLS certificates…" />
<div wire:loading.block wire:target="loadTraefikCertificates"
class="hidden overflow-hidden rounded-lg ring-1 ring-neutral-200 dark:ring-white/[0.08]">
<x-skeleton.table flush :rows="4" />
</div>
<div wire:loading.remove wire:target="loadTraefikCertificates">
@@ -34,10 +34,8 @@
</div>
<div x-init="$wire.initLoadDynamicConfigurations" class="contents">
<div wire:loading wire:target="initLoadDynamicConfigurations"
class="rounded-lg border border-neutral-200 p-6 dark:border-white/[0.08]">
<x-loading text="Loading dynamic configurations…" />
</div>
<x-skeleton.settings-sections wire:loading.flex wire:target="initLoadDynamicConfigurations"
label="Loading dynamic configurations" />
@if ($contents?->isNotEmpty())
@foreach ($contents as $fileName => $value)
@@ -8,8 +8,8 @@
@if (auth()->user()->isAdminFromSession())
@if ($loading)
<div class="flex min-h-80 items-center justify-center" wire:init="getStripeStatus">
<x-loading text="Loading your subscription status..." />
<div wire:init="getStripeStatus">
<x-skeleton.settings-sections :count="2" label="Loading your subscription status" />
</div>
@else
@if ($isUnpaid)
@@ -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())
@@ -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) {
@@ -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();
});