feat(admin): DB-driven storage sum + grouped error log viewer

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]>
This commit is contained in:
ghassanandClaude Opus 4.7 committed 2026-10-21 12:02:03 +03:00
1 parent 05ccb45fe4
commit 5adafc7616
2 files changed
+74 -15

No files matched your search

+18 -7
View File
@@ -188,16 +188,27 @@ class SuperAdminController extends Controller
$recentVideos = Video::with('user')->latest()->take(5)->get();
// ── Storage ───────────────────────────────────────────────
$disk = Storage::disk('public');
// NAS is the primary store; walking the local disk here would miss
// every file that's been pushed to NAS and purged locally. We use
// the DB-captured file sizes instead: videos.size is set at upload
// time, and thumbnail / avatar counts × a rough per-file average
// give us a usable picture. True NAS-side bytes could be queried
// with `smbclient du`, but that's slow and locks the dashboard; the
// DB figure is accurate for everything uploaded through this app.
$sizeVideos = (int) Video::sum('size'); // bytes
$thumbnailCount = Video::whereNotNull('thumbnail')->count();
$avatarCount = User::whereNotNull('avatar')->count();
$bannerCount = User::whereNotNull('banner')->count();
$postImageCount = \DB::table('post_images')->count();
$sizeVideos = $this->dirSize($disk, 'videos');
$sizeThumbnails = $this->dirSize($disk, 'thumbnails');
$sizeAvatars = $this->dirSize($disk, 'avatars');
$sizeImages = $this->dirSize($disk, 'images');
$totalPublicSize = $sizeVideos + $sizeThumbnails + $sizeAvatars + $sizeImages;
// Images are variable but ~120 KB is a solid conservative estimate
// for a web-sized JPEG/PNG upload.
$sizeImages = ($thumbnailCount + $avatarCount + $bannerCount + $postImageCount) * 120 * 1024;
$sizeThumbnails = $thumbnailCount * 120 * 1024;
$sizeAvatars = $avatarCount * 120 * 1024;
$totalPublicSize = $sizeVideos + $sizeImages;
$videosUsagePercent = $totalPublicSize > 0 ? round(($sizeVideos / $totalPublicSize) * 100, 1) : 0;
// Convert to MB
$toMb = fn($b) => round($b / 1024 / 1024, 1);
$storage = [
'videos' => $toMb($sizeVideos),
+56 -8
View File
@@ -64,25 +64,73 @@
</button>
</form>
{{-- Log lines --}}
{{-- 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;"></i>
<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($lines as $line)
@foreach($groups as $gi => $g)
@php
$color = '#aaa';
if (str_contains($line, '.ERROR:')) $color = '#f87171';
elseif (str_contains($line, '.WARNING:')) $color = '#fbbf24';
elseif (str_contains($line, '.INFO:')) $color = '#6ee7b7';
elseif (str_contains($line, '.DEBUG:')) $color = '#93c5fd';
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-line" style="color: {{ $color }}; border-bottom: 1px solid #1a1a1a; padding: 3px 0; white-space: pre-wrap; word-break: break-all;">{{ $line }}</div>
<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>