Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5aef41201a | ||
|
|
5adafc7616 | ||
|
|
05ccb45fe4 |
No files matched your search
@@ -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),
|
||||
|
||||
@@ -351,24 +351,40 @@
|
||||
Showing {{ $logs->firstItem() }}–{{ $logs->lastItem() }} of {{ $logs->total() }} events
|
||||
</div>
|
||||
<div class="al-page-links">
|
||||
{{-- First --}}
|
||||
@if($logs->onFirstPage())
|
||||
<span class="disabled"><i class="bi bi-chevron-left"></i></span>
|
||||
<span class="disabled" title="First page"><i class="bi bi-chevron-double-left" aria-hidden="true"></i></span>
|
||||
@else
|
||||
<a href="{{ $logs->previousPageUrl() }}"><i class="bi bi-chevron-left"></i></a>
|
||||
<a href="{{ $logs->url(1) }}" aria-label="First page" title="First page"><i class="bi bi-chevron-double-left" aria-hidden="true"></i></a>
|
||||
@endif
|
||||
|
||||
{{-- Prev --}}
|
||||
@if($logs->onFirstPage())
|
||||
<span class="disabled" title="Previous page"><i class="bi bi-chevron-left" aria-hidden="true"></i></span>
|
||||
@else
|
||||
<a href="{{ $logs->previousPageUrl() }}" aria-label="Previous page" title="Previous page"><i class="bi bi-chevron-left" aria-hidden="true"></i></a>
|
||||
@endif
|
||||
|
||||
@foreach($logs->getUrlRange(max(1, $logs->currentPage()-2), min($logs->lastPage(), $logs->currentPage()+2)) as $page => $url)
|
||||
@if($page == $logs->currentPage())
|
||||
<span class="active">{{ $page }}</span>
|
||||
<span class="active" aria-current="page">{{ $page }}</span>
|
||||
@else
|
||||
<a href="{{ $url }}">{{ $page }}</a>
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
{{-- Next --}}
|
||||
@if($logs->hasMorePages())
|
||||
<a href="{{ $logs->nextPageUrl() }}"><i class="bi bi-chevron-right"></i></a>
|
||||
<a href="{{ $logs->nextPageUrl() }}" aria-label="Next page" title="Next page"><i class="bi bi-chevron-right" aria-hidden="true"></i></a>
|
||||
@else
|
||||
<span class="disabled"><i class="bi bi-chevron-right"></i></span>
|
||||
<span class="disabled" title="Next page"><i class="bi bi-chevron-right" aria-hidden="true"></i></span>
|
||||
@endif
|
||||
|
||||
{{-- Last --}}
|
||||
@if($logs->currentPage() < $logs->lastPage())
|
||||
<a href="{{ $logs->url($logs->lastPage()) }}" aria-label="Last page" title="Last page ({{ $logs->lastPage() }})"><i class="bi bi-chevron-double-right" aria-hidden="true"></i></a>
|
||||
@else
|
||||
<span class="disabled" title="Last page"><i class="bi bi-chevron-double-right" aria-hidden="true"></i></span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -125,12 +125,25 @@ $adminNeedsOtp = auth()->user()->two_factor_enabled &&
|
||||
</div>
|
||||
@endif
|
||||
<div style="min-width:0;">
|
||||
<div style="font-weight:500;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px;">
|
||||
{{-- Full title in the title attribute so the
|
||||
admin can hover and read the whole thing
|
||||
when the visible row ellipsizes. --}}
|
||||
<div style="font-weight:500;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px;"
|
||||
title="{{ $video->title }}">
|
||||
{{ $video->title }}
|
||||
</div>
|
||||
@if($video->description)
|
||||
<div style="font-size:11px;color:var(--text-2);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px;">
|
||||
{{ Str::limit($video->description, 55) }}
|
||||
@php
|
||||
// Descriptions are stored as sanitized rich HTML
|
||||
// (see HtmlSanitizer). Blade's {{ }} would show
|
||||
// the escaped tags verbatim in the admin table,
|
||||
// so strip them to a plain-text preview — and
|
||||
// put the full stripped text in the tooltip too.
|
||||
$descPlain = trim(preg_replace('/\s+/', ' ', strip_tags((string) $video->description)));
|
||||
@endphp
|
||||
<div style="font-size:11px;color:var(--text-2);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px;"
|
||||
title="{{ $descPlain }}">
|
||||
{{ Str::limit($descPlain, 55) }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -37,14 +37,25 @@ a.pag-item:hover {
|
||||
@endonce
|
||||
<nav class="pag-nav" aria-label="Pagination">
|
||||
|
||||
{{-- Prev --}}
|
||||
{{-- First --}}
|
||||
@if ($paginator->onFirstPage())
|
||||
<span class="pag-item disabled" aria-disabled="true">
|
||||
<i class="bi bi-chevron-left" style="font-size:11px;"></i>
|
||||
<span class="pag-item disabled" aria-disabled="true" title="First page">
|
||||
<i class="bi bi-chevron-double-left" style="font-size:11px;" aria-hidden="true"></i>
|
||||
</span>
|
||||
@else
|
||||
<a class="pag-item" href="{{ $paginator->previousPageUrl() }}" rel="prev" aria-label="Previous">
|
||||
<i class="bi bi-chevron-left" style="font-size:11px;"></i>
|
||||
<a class="pag-item" href="{{ $paginator->url(1) }}" aria-label="First page" title="First page">
|
||||
<i class="bi bi-chevron-double-left" style="font-size:11px;" aria-hidden="true"></i>
|
||||
</a>
|
||||
@endif
|
||||
|
||||
{{-- Prev --}}
|
||||
@if ($paginator->onFirstPage())
|
||||
<span class="pag-item disabled" aria-disabled="true" title="Previous page">
|
||||
<i class="bi bi-chevron-left" style="font-size:11px;" aria-hidden="true"></i>
|
||||
</span>
|
||||
@else
|
||||
<a class="pag-item" href="{{ $paginator->previousPageUrl() }}" rel="prev" aria-label="Previous page" title="Previous page">
|
||||
<i class="bi bi-chevron-left" style="font-size:11px;" aria-hidden="true"></i>
|
||||
</a>
|
||||
@endif
|
||||
|
||||
@@ -66,12 +77,23 @@ a.pag-item:hover {
|
||||
|
||||
{{-- Next --}}
|
||||
@if ($paginator->hasMorePages())
|
||||
<a class="pag-item" href="{{ $paginator->nextPageUrl() }}" rel="next" aria-label="Next">
|
||||
<i class="bi bi-chevron-right" style="font-size:11px;"></i>
|
||||
<a class="pag-item" href="{{ $paginator->nextPageUrl() }}" rel="next" aria-label="Next page" title="Next page">
|
||||
<i class="bi bi-chevron-right" style="font-size:11px;" aria-hidden="true"></i>
|
||||
</a>
|
||||
@else
|
||||
<span class="pag-item disabled" aria-disabled="true">
|
||||
<i class="bi bi-chevron-right" style="font-size:11px;"></i>
|
||||
<span class="pag-item disabled" aria-disabled="true" title="Next page">
|
||||
<i class="bi bi-chevron-right" style="font-size:11px;" aria-hidden="true"></i>
|
||||
</span>
|
||||
@endif
|
||||
|
||||
{{-- Last --}}
|
||||
@if ($paginator->hasMorePages() && $paginator->lastPage() > $paginator->currentPage())
|
||||
<a class="pag-item" href="{{ $paginator->url($paginator->lastPage()) }}" aria-label="Last page" title="Last page ({{ $paginator->lastPage() }})">
|
||||
<i class="bi bi-chevron-double-right" style="font-size:11px;" aria-hidden="true"></i>
|
||||
</a>
|
||||
@else
|
||||
<span class="pag-item disabled" aria-disabled="true" title="Last page">
|
||||
<i class="bi bi-chevron-double-right" style="font-size:11px;" aria-hidden="true"></i>
|
||||
</span>
|
||||
@endif
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Http\Middleware\VerifyCsrfToken;
|
||||
use App\Models\User;
|
||||
use App\Models\Video;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Admin polish follow-ups to Phase 7 — storage math, pagination controls,
|
||||
* HTML-stripped descriptions and the grouped error log viewer.
|
||||
*/
|
||||
class AdminPolishTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
putenv('DB_CONNECTION=sqlite');
|
||||
putenv('DB_DATABASE=:memory:');
|
||||
$_ENV['DB_CONNECTION'] = 'sqlite';
|
||||
$_ENV['DB_DATABASE'] = ':memory:';
|
||||
|
||||
parent::setUp();
|
||||
|
||||
$this->withoutMiddleware([
|
||||
VerifyCsrfToken::class,
|
||||
\Illuminate\Session\Middleware\AuthenticateSession::class,
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── Pagination partial: First + Last buttons ─────
|
||||
|
||||
public function test_pagination_partial_renders_first_and_last_controls(): void
|
||||
{
|
||||
$paginator = new LengthAwarePaginator(
|
||||
items: ['a', 'b', 'c'],
|
||||
total: 100,
|
||||
perPage: 10,
|
||||
currentPage: 5,
|
||||
options: ['path' => '/videos']
|
||||
);
|
||||
|
||||
$html = view('partials.pagination', [
|
||||
'paginator' => $paginator,
|
||||
'elements' => $paginator->linkCollection()->toArray(),
|
||||
])->render();
|
||||
|
||||
$this->assertStringContainsString('aria-label="First page"', $html);
|
||||
$this->assertStringContainsString('aria-label="Last page"', $html);
|
||||
$this->assertStringContainsString('aria-label="Previous page"', $html);
|
||||
$this->assertStringContainsString('aria-label="Next page"', $html);
|
||||
|
||||
// The anchors should link to page 1 and page 10 respectively.
|
||||
$this->assertStringContainsString('href="/videos?page=1"', $html);
|
||||
$this->assertStringContainsString('href="/videos?page=10"', $html);
|
||||
}
|
||||
|
||||
public function test_pagination_disables_first_on_page_one(): void
|
||||
{
|
||||
// Two-page paginator — if there's only one page the partial
|
||||
// short-circuits via @if($paginator->hasPages()) and renders
|
||||
// nothing, which isn't what we're testing.
|
||||
$paginator = new LengthAwarePaginator(
|
||||
items: ['a'],
|
||||
total: 20,
|
||||
perPage: 10,
|
||||
currentPage: 1,
|
||||
options: ['path' => '/videos']
|
||||
);
|
||||
|
||||
$html = view('partials.pagination', [
|
||||
'paginator' => $paginator,
|
||||
'elements' => $paginator->linkCollection()->toArray(),
|
||||
])->render();
|
||||
|
||||
// On page 1 the First button is a disabled span, not an anchor.
|
||||
$this->assertMatchesRegularExpression(
|
||||
'/<span[^>]+class="pag-item disabled"[^>]+title="First page"/',
|
||||
$html
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Admin videos list: HTML-stripped description ─
|
||||
|
||||
public function test_admin_video_description_renders_without_html_tags(): void
|
||||
{
|
||||
$owner = User::factory()->create([
|
||||
'username' => 'u'.substr(bin2hex(random_bytes(4)), 0, 8),
|
||||
'role' => 'super_admin',
|
||||
]);
|
||||
$video = Video::create([
|
||||
'user_id' => $owner->id,
|
||||
'title' => 'The Full Video Title That Will Be Ellipsized',
|
||||
'description' => '<p>Hello <strong>world</strong> <a href="#">link</a></p>',
|
||||
'filename' => 'users/x/videos/y/video.mp4',
|
||||
'path' => 'users/x/videos/y/video.mp4',
|
||||
'type' => 'generic',
|
||||
'visibility' => 'public',
|
||||
'status' => 'ready',
|
||||
'share_token' => bin2hex(random_bytes(8)),
|
||||
]);
|
||||
$this->actingAs($owner);
|
||||
|
||||
$html = $this->get('/admin/videos')->getContent();
|
||||
|
||||
// Admin list must render plain text, not literal <p> / <strong>.
|
||||
$this->assertStringNotContainsString('<p>Hello', $html,
|
||||
'Description must be stripped of HTML tags before display.');
|
||||
$this->assertStringContainsString('Hello world link', $html,
|
||||
'Stripped text should still be visible.');
|
||||
// Title tooltip in both the title column and description preview.
|
||||
$this->assertStringContainsString(
|
||||
'title="The Full Video Title That Will Be Ellipsized"',
|
||||
$html,
|
||||
'Admin title column must carry a title="" attribute for the full name.'
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Dashboard: Ready label ──────────────────────
|
||||
|
||||
public function test_dashboard_ready_stat_card_uses_the_new_label(): void
|
||||
{
|
||||
$owner = User::factory()->create([
|
||||
'username' => 'ad'.substr(bin2hex(random_bytes(4)), 0, 6),
|
||||
'role' => 'super_admin',
|
||||
]);
|
||||
$this->actingAs($owner);
|
||||
|
||||
$html = $this->get('/admin/dashboard')->getContent();
|
||||
|
||||
$this->assertStringContainsString('>Ready<', $html);
|
||||
$this->assertStringNotContainsString('>Live Videos<', $html,
|
||||
'The old "Live Videos" stat-card label should be gone.');
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user