General
Log Viewer code
Copy the implementation or inspect every file that belongs to this component unit.
resources/views/components/ui/log-viewer.blade.php
@props([
'entries' => [],
'title' => 'Log stream',
'description' => null,
'source' => null,
'live' => false,
'search' => true,
'levels' => true,
'maxHeight' => '32rem',
'emptyTitle' => 'No log entries',
'emptyDescription' => 'Logs will appear here as they are recorded.',
])
@php
/*
| Structured application log viewer. Renders timestamped, level-coded entries
| with an optional channel, a message, and an expandable context payload.
| Entries are rendered server-side (so they remain accessible without JS) and
| Alpine layers on client-side level filtering and full-text search. Unlike
| the terminal-style <x-ui.forge-deploy-log-viewer>, this reads like an
| application log table.
|
| Each entry accepts:
| level => debug|info|notice|warning|error|critical
| timestamp => string (e.g. '12:04:51' or '2026-06-24 12:04:51')
| channel => optional source/channel label (e.g. 'queue', 'auth')
| message => string
| context => optional string or array rendered as an expandable payload
|
| Usage:
| <x-ui.log-viewer :entries="$entries" source="laravel.log" live />
*/
$levelMeta = [
'debug' => ['label' => 'DEBUG', 'dot' => 'bg-gray-400 dark:bg-gray-500', 'text' => 'text-gray-500 dark:text-gray-400'],
'info' => ['label' => 'INFO', 'dot' => 'bg-accent-500 dark:bg-accent-400', 'text' => 'text-accent-600 dark:text-accent-300'],
'notice' => ['label' => 'NOTICE', 'dot' => 'bg-brand-500 dark:bg-brand-400', 'text' => 'text-brand-600 dark:text-brand-300'],
'warning' => ['label' => 'WARN', 'dot' => 'bg-amber-500 dark:bg-amber-400', 'text' => 'text-amber-600 dark:text-amber-300'],
'error' => ['label' => 'ERROR', 'dot' => 'bg-red-500 dark:bg-red-400', 'text' => 'text-red-600 dark:text-red-300'],
'critical' => ['label' => 'CRIT', 'dot' => 'bg-red-600 dark:bg-red-500', 'text' => 'text-red-700 dark:text-red-300'],
];
$normalized = collect($entries)->map(function (array $entry, int $index) use ($levelMeta): array {
$level = strtolower((string) ($entry['level'] ?? 'info'));
$level = array_key_exists($level, $levelMeta) ? $level : 'info';
$context = $entry['context'] ?? null;
if (is_array($context)) {
$context = json_encode($context, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
$message = (string) ($entry['message'] ?? '');
$channel = $entry['channel'] ?? $entry['source'] ?? null;
return [
'id' => 'log-'.$index,
'level' => $level,
'timestamp' => (string) ($entry['timestamp'] ?? $entry['time'] ?? ''),
'channel' => $channel,
'message' => $message,
'context' => filled($context) ? (string) $context : null,
'search' => strtolower(trim($message.' '.($channel ?? '').' '.($context ?? ''))),
];
})->values();
$counts = $normalized->groupBy('level')->map->count();
$total = $normalized->count();
$issues = ($counts['error'] ?? 0) + ($counts['critical'] ?? 0) + ($counts['warning'] ?? 0);
$availableLevels = collect(array_keys($levelMeta))
->filter(fn (string $level): bool => ($counts[$level] ?? 0) > 0)
->values();
$filterMeta = $normalized->map(fn (array $entry): array => [
'level' => $entry['level'],
'search' => $entry['search'],
])->values();
@endphp
<section
x-data="{
query: '',
level: 'all',
rows: @js($filterMeta),
matches(row) {
if (this.level !== 'all' && row.level !== this.level) { return false; }
const needle = this.query.trim().toLowerCase();
if (needle && ! row.search.includes(needle)) { return false; }
return true;
},
rowMatches(el) {
return this.matches({ level: el.dataset.level, search: el.dataset.search });
},
get visible() {
return this.rows.filter((row) => this.matches(row)).length;
},
}"
{{ $attributes->merge(['class' => 'min-w-0 overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900']) }}
>
<div class="flex flex-col gap-4 px-5 py-4 sm:px-6">
<div class="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
<div class="min-w-0">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">{{ $title }}</h3>
@if ($description)
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ $description }}</p>
@endif
</div>
<div class="flex shrink-0 flex-wrap items-center gap-2">
@if ($source)
<x-ui.badge variant="gray" :pill="false" icon="file-text" icon-weight="regular">
<span class="font-mono">{{ $source }}</span>
</x-ui.badge>
@endif
@if ($live)
<span class="inline-flex items-center gap-1.5 rounded-full bg-green-50 px-2.5 py-1 text-xs font-medium text-green-700 dark:bg-green-950/60 dark:text-green-300">
<span class="relative flex h-2 w-2" aria-hidden="true">
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-500 opacity-75"></span>
<span class="relative inline-flex h-2 w-2 rounded-full bg-green-500"></span>
</span>
Live
</span>
@endif
</div>
</div>
@if (($search || $levels) && $total > 0)
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
@if ($levels)
<div class="flex flex-wrap items-center gap-1.5" role="group" aria-label="Filter by level">
<button
type="button"
x-on:click="level = 'all'"
:class="level === 'all' ? 'bg-gray-900 text-white dark:bg-white dark:text-gray-900' : 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'"
class="rounded-md px-2.5 py-1 text-xs font-medium tabular-nums transition"
>
All <span class="tabular-nums opacity-70">{{ $total }}</span>
</button>
@foreach ($availableLevels as $level)
@php($meta = $levelMeta[$level])
<button
type="button"
x-on:click="level = @js($level)"
:class="level === @js($level) ? 'ring-2 ring-offset-1 ring-gray-400 dark:ring-gray-500 dark:ring-offset-gray-900' : ''"
class="inline-flex items-center gap-1.5 rounded-md bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-600 transition hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
<span class="h-1.5 w-1.5 rounded-full {{ $meta['dot'] }}" aria-hidden="true"></span>
{{ $meta['label'] }}
<span class="tabular-nums opacity-70">{{ $counts[$level] ?? 0 }}</span>
</button>
@endforeach
</div>
@endif
@if ($search)
<div class="relative sm:w-64">
<span class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-gray-400">
<x-ui.icon name="magnifying-glass" size="sm" />
</span>
<input
type="search"
x-model="query"
placeholder="Filter logs..."
aria-label="Filter logs"
class="w-full rounded-lg border border-gray-300 bg-white py-1.5 pl-9 pr-3 text-sm text-gray-900 placeholder:text-gray-400 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 dark:border-gray-700 dark:bg-gray-950 dark:text-gray-100"
>
</div>
@endif
</div>
@endif
</div>
@if ($total > 0)
<div class="overflow-auto border-t border-gray-200 font-mono text-[13px] leading-relaxed dark:border-gray-800" style="max-height: {{ $maxHeight }}">
<ul class="divide-y divide-gray-100 dark:divide-gray-800/60">
@foreach ($normalized as $entry)
@php($meta = $levelMeta[$entry['level']])
<li
class="group"
data-level="{{ $entry['level'] }}"
data-search="{{ $entry['search'] }}"
x-show="rowMatches($el)"
@if ($entry['context']) x-data="{ open: false }" @endif
>
<div
class="flex min-w-0 items-start gap-3 px-5 py-2 transition hover:bg-gray-50 dark:hover:bg-gray-800/40 sm:px-6 {{ $entry['context'] ? 'cursor-pointer' : '' }}"
@if ($entry['context']) x-on:click="open = ! open" @endif
>
<span class="mt-1.5 hidden h-1.5 w-1.5 shrink-0 rounded-full sm:block {{ $meta['dot'] }}" aria-hidden="true"></span>
<span class="shrink-0 select-none tabular-nums text-xs text-gray-400 dark:text-gray-500">{{ $entry['timestamp'] }}</span>
<span class="w-14 shrink-0 text-xs font-semibold uppercase {{ $meta['text'] }}">{{ $meta['label'] }}</span>
<span class="min-w-0 flex-1">
@if ($entry['channel'])
<span class="mr-1.5 rounded bg-gray-100 px-1.5 py-0.5 text-xs text-gray-500 dark:bg-gray-800 dark:text-gray-400">{{ $entry['channel'] }}</span>
@endif
<span class="break-words text-gray-800 dark:text-gray-100">{{ $entry['message'] }}</span>
</span>
@if ($entry['context'])
<span class="mt-0.5 shrink-0 text-gray-400 transition group-hover:text-gray-600 dark:group-hover:text-gray-300" aria-hidden="true">
<x-ui.icon name="caret-down" size="xs" x-bind:class="open ? 'rotate-180' : ''" class="transition-transform" />
</span>
@endif
</div>
@if ($entry['context'])
<pre x-show="open" x-cloak class="overflow-x-auto border-t border-gray-100 bg-gray-50 px-5 py-3 text-xs text-gray-600 dark:border-gray-800/60 dark:bg-gray-950/60 dark:text-gray-300 sm:px-6"><code>{{ $entry['context'] }}</code></pre>
@endif
</li>
@endforeach
</ul>
<div x-show="visible === 0" x-cloak class="px-6 py-12 text-center text-sm text-gray-500 dark:text-gray-400">
No log entries match the current filters.
</div>
</div>
<div class="flex flex-wrap items-center justify-between gap-2 border-t border-gray-200 px-5 py-3 text-xs text-gray-500 dark:border-gray-800 dark:text-gray-400 sm:px-6">
<span>
Showing <span class="font-medium tabular-nums text-gray-700 dark:text-gray-200" x-text="visible">{{ $total }}</span>
of <span class="tabular-nums">{{ $total }}</span> entries
</span>
@if ($issues > 0)
<span class="inline-flex items-center gap-1.5 text-amber-600 dark:text-amber-300">
<x-ui.icon name="warning" size="xs" weight="fill" />
{{ $issues }} need attention
</span>
@endif
</div>
@else
<x-ui.empty-state :title="$emptyTitle" :description="$emptyDescription" icon="list-magnifying-glass" class="border-t border-gray-200 dark:border-gray-800" />
@endif
</section>