Storage:
- The dashboard storage card used to walk Storage::disk('public')'s
videos / thumbnails / avatars / images directories. NAS is primary
storage, so for every file that's been pushed-and-purged (most of
them, per CLAUDE.md), the disk walk reports 0. Replace with a
DB-driven estimate: SUM(videos.size) for video bytes plus a 120 KB
conservative per-file estimate × (thumbnails + avatars + banners +
post images). Fast (3 count queries + one SUM), works regardless
of local-cache state, and now reflects content that lives on NAS.
Error logs:
- Flat view used to render every line as its own equal-weight <div>,
so a stack trace of 70 frames buried the next real event. Group
lines by header: a line that matches .(ERROR|WARNING|INFO|DEBUG|
CRITICAL|…): starts a new group; subsequent lines belong to its
stack trace. Each group collapses by default behind a caret the
admin can expand. Stack-trace lines preserve their natural #0 → #N
order inside the group even when the file is reversed newest-first.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
149 lines
7.9 KiB
PHP
149 lines
7.9 KiB
PHP
@extends('layouts.app')
|
|
|
|
@section('title', 'Error Logs | Admin')
|
|
|
|
@section('content')
|
|
<div style="max-width: 1400px; margin: 0 auto; padding: 24px;">
|
|
|
|
{{-- Header --}}
|
|
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; flex-wrap: wrap; gap: 12px;">
|
|
<div style="display: flex; align-items: center; gap: 12px;">
|
|
<a href="{{ route('admin.dashboard') }}" class="action-btn">
|
|
<i class="bi bi-arrow-left"></i> <span>Dashboard</span>
|
|
</a>
|
|
<h1 style="font-size: 22px; font-weight: 700; margin: 0;">
|
|
<i class="bi bi-bug-fill" style="color: #e61e1e; margin-right: 8px;"></i>Error Logs
|
|
</h1>
|
|
</div>
|
|
<div style="display: flex; gap: 8px; align-items: center;">
|
|
<span style="font-size: 13px; color: var(--text-secondary);">{{ count($lines) }} entries shown</span>
|
|
<a href="{{ route('admin.logs') }}" class="action-btn">
|
|
<i class="bi bi-arrow-clockwise"></i> <span>Refresh</span>
|
|
</a>
|
|
<button class="action-btn" onclick="copyAll()">
|
|
<i class="bi bi-clipboard"></i> <span>Copy All</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Filters --}}
|
|
<form method="GET" action="{{ route('admin.logs') }}"
|
|
style="display: flex; gap: 10px; margin-bottom: 20px; flex-wrap: wrap; align-items: flex-end;">
|
|
|
|
<div style="flex: 1; min-width: 200px;">
|
|
<label style="display: block; font-size: 12px; color: var(--text-secondary); margin-bottom: 4px;">Search</label>
|
|
<input type="text" name="filter" value="{{ $filter }}"
|
|
placeholder="Filter by keyword…"
|
|
style="width: 100%; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 8px; padding: 8px 12px; font-size: 13px;">
|
|
</div>
|
|
|
|
<div>
|
|
<label style="display: block; font-size: 12px; color: var(--text-secondary); margin-bottom: 4px;">Level</label>
|
|
<select name="level"
|
|
style="background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 8px; padding: 8px 12px; font-size: 13px; cursor: pointer;">
|
|
<option value="" {{ $level === '' ? 'selected' : '' }}>All levels</option>
|
|
<option value="ERROR" {{ $level === 'ERROR' ? 'selected' : '' }}>ERROR</option>
|
|
<option value="WARNING" {{ $level === 'WARNING' ? 'selected' : '' }}>WARNING</option>
|
|
<option value="INFO" {{ $level === 'INFO' ? 'selected' : '' }}>INFO</option>
|
|
<option value="DEBUG" {{ $level === 'DEBUG' ? 'selected' : '' }}>DEBUG</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label style="display: block; font-size: 12px; color: var(--text-secondary); margin-bottom: 4px;">Show</label>
|
|
<select name="limit"
|
|
style="background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 8px; padding: 8px 12px; font-size: 13px; cursor: pointer;">
|
|
@foreach([50, 100, 200, 500] as $n)
|
|
<option value="{{ $n }}" {{ $limit == $n ? 'selected' : '' }}>{{ $n }} lines</option>
|
|
@endforeach
|
|
</select>
|
|
</div>
|
|
|
|
<button type="submit" class="action-btn action-btn-primary">
|
|
<i class="bi bi-search"></i> <span>Filter</span>
|
|
</button>
|
|
</form>
|
|
|
|
{{-- Log lines, grouped — each entry's stack trace (lines beginning with
|
|
`#N `, `[stacktrace]`, or `Stack trace:`) is folded into its parent
|
|
header line. Headers show their level color; stack trace lines
|
|
render in a muted indent and preserve their natural #0 → #N order
|
|
inside each group even when the overall list is newest-first. --}}
|
|
@if(empty($lines))
|
|
<div style="text-align: center; padding: 60px; color: var(--text-secondary);">
|
|
<i class="bi bi-check-circle" style="font-size: 48px; color: #22c55e; display: block; margin-bottom: 12px;" aria-hidden="true"></i>
|
|
No log entries found{{ $filter || $level ? ' matching your filter' : '' }}.
|
|
</div>
|
|
@else
|
|
@php
|
|
// Group the flat list of lines into [{header, trace[]}, …].
|
|
// Any line that is NOT a header starts/continues the current
|
|
// group's trace. Lines that look like a header (ERROR/WARNING/
|
|
// INFO/DEBUG) start a new group.
|
|
$groups = [];
|
|
$current = null;
|
|
$isHeader = fn ($l) => (bool) preg_match('/\.(ERROR|WARNING|INFO|DEBUG|CRITICAL|ALERT|EMERGENCY|NOTICE):/', $l);
|
|
foreach ($lines as $line) {
|
|
if ($isHeader($line) || $current === null) {
|
|
if ($current) $groups[] = $current;
|
|
$current = ['header' => $line, 'trace' => []];
|
|
} else {
|
|
$current['trace'][] = $line;
|
|
}
|
|
}
|
|
if ($current) $groups[] = $current;
|
|
@endphp
|
|
<div id="logOutput" style="font-family: 'Courier New', monospace; font-size: 12px; line-height: 1.6; background: #0a0a0a; border: 1px solid var(--border-color); border-radius: 10px; overflow: auto; max-height: calc(100vh - 260px); padding: 12px 16px;">
|
|
@foreach($groups as $gi => $g)
|
|
@php
|
|
$color = '#aaa';
|
|
if (str_contains($g['header'], '.ERROR:') || str_contains($g['header'], '.CRITICAL:')) $color = '#f87171';
|
|
elseif (str_contains($g['header'], '.WARNING:')) $color = '#fbbf24';
|
|
elseif (str_contains($g['header'], '.INFO:')) $color = '#6ee7b7';
|
|
elseif (str_contains($g['header'], '.DEBUG:')) $color = '#93c5fd';
|
|
$hasTrace = !empty($g['trace']);
|
|
@endphp
|
|
<div class="log-group" style="border-bottom: 1px solid #1a1a1a; padding: 4px 0;">
|
|
<div class="log-line log-head" style="color: {{ $color }}; white-space: pre-wrap; word-break: break-all; display:flex; gap:8px; align-items:flex-start;">
|
|
@if($hasTrace)
|
|
<button type="button"
|
|
onclick="this.closest('.log-group').classList.toggle('open')"
|
|
aria-label="Toggle stack trace"
|
|
style="flex:none;background:none;border:none;color:inherit;font-family:inherit;font-size:11px;cursor:pointer;padding:0 4px;opacity:.75;">
|
|
<i class="bi bi-chevron-right log-caret" aria-hidden="true"></i>
|
|
</button>
|
|
@else
|
|
<span style="flex:none;display:inline-block;width:20px;"></span>
|
|
@endif
|
|
<span>{{ $g['header'] }}</span>
|
|
</div>
|
|
@if($hasTrace)
|
|
<div class="log-trace" style="display:none; padding: 4px 0 4px 28px; color: #6b7280; white-space: pre-wrap; word-break: break-all;">
|
|
@foreach($g['trace'] as $tl)
|
|
<div>{{ $tl }}</div>
|
|
@endforeach
|
|
</div>
|
|
@endif
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
<style>
|
|
.log-group.open .log-trace { display: block !important; }
|
|
.log-group.open .log-caret::before { content: "\F282"; /* bi-chevron-down */ }
|
|
</style>
|
|
@endif
|
|
|
|
</div>
|
|
|
|
<script>
|
|
function copyAll() {
|
|
const text = Array.from(document.querySelectorAll('.log-line'))
|
|
.map(el => el.textContent).join('\n');
|
|
navigator.clipboard.writeText(text).then(() => showToast('Copied to clipboard', 'success'));
|
|
}
|
|
|
|
// Auto-scroll to top (newest entries are at top)
|
|
document.getElementById('logOutput')?.scrollTo(0, 0);
|
|
</script>
|
|
@endsection
|