Skip to main content

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>