From c6597be59ed2a728e89172deba32f0b740b022e3 Mon Sep 17 00:00:00 2001 From: boban Date: Sun, 21 Jun 2026 23:58:27 +0200 Subject: [PATCH] feat(audit): paginate the log + DB-wide search (v0.9.50) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit log loaded the latest 50 entries and filtered them in memory, so it couldn't grow and search only spanned those 50. Now it is paginated (25/page) with the page deep-linked in the URL via Livewire WithPagination (?page=), and the search runs in SQL across the whole history — matching actor / action code / target / server name AND the readable labels (a term is translated to the action codes whose localized label contains it, so "Endpoint geändert" still finds wg.set-endpoint). Pagination control mirrors the changelog's windowed pager. Tests: 25/page with page 2 overflow, search resets to page 1, search filters in SQL across all pages (a match on page 2 surfaces when filtered). 365 pass, Pint clean, /audit loads 200 with no console errors, audit lang parity 62/62. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 11 ++- app/Livewire/Audit/Index.php | 87 +++++++++++++++---- config/clusev.php | 2 +- lang/de/audit.php | 5 +- lang/en/audit.php | 5 +- .../views/livewire/audit/index.blade.php | 35 +++++++- tests/Feature/AuditLogDisplayTest.php | 38 ++++++++ 7 files changed, 159 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0f0830..8ac9f84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,16 @@ getaggte Releases (Kanal `stable`, optional `beta`) — niemals Entwicklungs-Bui _Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._ -## [0.9.49] - 2026-06-21 +## [0.9.50] - 2026-06-21 + +### Hinzugefügt +- **Audit-Log mit Seiten-Navigation.** Das Log war auf die letzten 50 Einträge begrenzt; jetzt ist es + paginiert (25 pro Seite), die aktuelle Seite steht in der URL (`?page=`) und ist verlinkbar. + +### Geändert +- **Audit-Suche durchsucht den gesamten Verlauf.** Die Filterung läuft jetzt in der Datenbank über + alle Einträge (nicht nur die geladene Seite) — über Akteur, Aktion, Ziel, Server **und** die + lesbaren Bezeichnungen (z. B. findet „Endpoint geändert" die `wg.set-endpoint`-Einträge). ### Hinzugefügt - **Was ist neu — vor dem Update sichtbar.** Ist eine neue Version verfügbar, zeigt die Version-Seite diff --git a/app/Livewire/Audit/Index.php b/app/Livewire/Audit/Index.php index 498c049..30c28e9 100644 --- a/app/Livewire/Audit/Index.php +++ b/app/Livewire/Audit/Index.php @@ -4,14 +4,21 @@ namespace App\Livewire\Audit; use App\Models\AuditEvent; use App\Models\Setting; -use Illuminate\Support\Collection; +use Illuminate\Contracts\Pagination\LengthAwarePaginator; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\Auth; use Livewire\Attributes\Layout; use Livewire\Component; +use Livewire\WithPagination; #[Layout('layouts.app')] class Index extends Component { + use WithPagination; + + /** Audit entries per page — keeps a long history navigable (page is deep-linked via ?page=). */ + private const PER_PAGE = 25; + /** Freitext-Filter über Akteur / Aktion / Ziel / Server. */ public string $q = ''; @@ -28,6 +35,12 @@ class Index extends Component $this->retentionDays = $stored > 0 ? (string) $stored : null; } + /** A changed search must restart at page 1 — never land on an out-of-range page. */ + public function updatedQ(): void + { + $this->resetPage(); + } + /** Persist the retention policy, audit the change and notify the operator. */ public function saveRetention(): void { @@ -62,38 +75,74 @@ class Index extends Component } /** - * Letzte Audit-Ereignisse, optional gefiltert. Echte Daten aus der DB; - * die Filterung läuft im Speicher über die geladenen 50 Einträge. + * Audit events, newest first, optionally filtered. The filter runs in SQL (so it spans the whole + * history, not just one page) and matches actor / action code / target / server name — plus the + * READABLE action labels: a term is translated to the action codes whose localized label contains + * it, so searching "Endpoint geändert" still finds wg.set-endpoint entries. */ - protected function events(): Collection + protected function eventsQuery(): Builder { - $events = AuditEvent::with('server')->latest()->limit(50)->get(); + $query = AuditEvent::with('server')->latest(); $needle = trim($this->q); - if ($needle === '') { - return $events; + return $query; } - $needle = mb_strtolower($needle); + $lower = mb_strtolower($needle); + $like = '%'.$lower.'%'; + $codes = array_keys(array_filter( + (array) trans('audit.actions'), + fn ($label): bool => is_string($label) && str_contains(mb_strtolower($label), $lower), + )); - return $events->filter(function (AuditEvent $e) use ($needle): bool { - $haystack = mb_strtolower(implode(' ', array_filter([ - $e->actor, - $e->action, - $e->action_label, - $e->target, - $e->server?->name, - ]))); + return $query->where(function (Builder $w) use ($like, $codes): void { + $w->whereRaw('LOWER(actor) LIKE ?', [$like]) + ->orWhereRaw('LOWER(action) LIKE ?', [$like]) + ->orWhereRaw('LOWER(target) LIKE ?', [$like]) + ->orWhereHas('server', fn (Builder $s) => $s->whereRaw('LOWER(name) LIKE ?', [$like])); + if ($codes !== []) { + $w->orWhereIn('action', $codes); + } + }); + } - return str_contains($haystack, $needle); - })->values(); + /** + * Page numbers to render, with "…" gaps for long histories (1 … 4 5 6 … 20). + * + * @return array + */ + protected function pageWindow(int $current, int $total): array + { + if ($total <= 7) { + return range(1, max(1, $total)); + } + + $pages = [1]; + $from = max(2, $current - 1); + $to = min($total - 1, $current + 1); + if ($from > 2) { + $pages[] = '…'; + } + for ($i = $from; $i <= $to; $i++) { + $pages[] = $i; + } + if ($to < $total - 1) { + $pages[] = '…'; + } + $pages[] = $total; + + return $pages; } public function render() { + /** @var LengthAwarePaginator $events */ + $events = $this->eventsQuery()->paginate(self::PER_PAGE); + return view('livewire.audit.index', [ - 'events' => $this->events(), + 'events' => $events, + 'pageWindow' => $this->pageWindow($events->currentPage(), $events->lastPage()), 'retentionPolicy' => $this->retentionPolicy(), ])->title(__('audit.title')); } diff --git a/config/clusev.php b/config/clusev.php index 7b66523..bbde523 100644 --- a/config/clusev.php +++ b/config/clusev.php @@ -2,7 +2,7 @@ return [ // First tagged release is v0.1.0 (semantic, not -dev). - 'version' => '0.9.49', + 'version' => '0.9.50', // Deployed commit + branch. install.sh bakes these into .env from the host's .git (the prod // image ships no .git); the Versions page prefers them and falls back to a live .git read in diff --git a/lang/de/audit.php b/lang/de/audit.php index 0e6fdf4..6cd74ce 100644 --- a/lang/de/audit.php +++ b/lang/de/audit.php @@ -9,7 +9,10 @@ return [ // Events panel 'panel_title' => 'Ereignisse', - 'panel_subtitle' => 'letzte 50 · neueste zuerst', + 'panel_subtitle' => 'neueste zuerst', + 'pagination_nav' => 'Audit-Seiten', + 'pagination_prev' => 'Zurück', + 'pagination_next' => 'Weiter', 'search_label' => 'Audit-Log durchsuchen', 'search_placeholder' => 'Akteur, Aktion, Ziel …', diff --git a/lang/en/audit.php b/lang/en/audit.php index f867b00..7665da0 100644 --- a/lang/en/audit.php +++ b/lang/en/audit.php @@ -9,7 +9,10 @@ return [ // Events panel 'panel_title' => 'Events', - 'panel_subtitle' => 'last 50 · newest first', + 'panel_subtitle' => 'newest first', + 'pagination_nav' => 'Audit pages', + 'pagination_prev' => 'Previous', + 'pagination_next' => 'Next', 'search_label' => 'Search audit log', 'search_placeholder' => 'Actor, action, target …', diff --git a/resources/views/livewire/audit/index.blade.php b/resources/views/livewire/audit/index.blade.php index 373d5d8..3314c9b 100644 --- a/resources/views/livewire/audit/index.blade.php +++ b/resources/views/livewire/audit/index.blade.php @@ -5,7 +5,7 @@

{{ __('audit.eyebrow') }}

{{ __('audit.heading') }}

- {{ __('audit.event_count', ['count' => $events->count()]) }} + {{ __('audit.event_count', ['count' => $events->total()]) }} {{-- Aufbewahrung (Retention) --}} @@ -116,5 +116,38 @@ @endforelse + + {{-- Pagination — page is deep-linked via ?page= (Livewire WithPagination). --}} + @if ($events->lastPage() > 1) + + @endif diff --git a/tests/Feature/AuditLogDisplayTest.php b/tests/Feature/AuditLogDisplayTest.php index 2f2e7f9..b59e0f3 100644 --- a/tests/Feature/AuditLogDisplayTest.php +++ b/tests/Feature/AuditLogDisplayTest.php @@ -58,4 +58,42 @@ class AuditLogDisplayTest extends TestCase ->assertSee('WireGuard-Endpoint geändert') ->assertDontSee('Benutzer angelegt'); } + + public function test_paginates_the_audit_log_with_the_page_in_the_url(): void + { + for ($i = 0; $i < 30; $i++) { + AuditEvent::create(['actor' => 'A', 'action' => 'user.create', 'target' => "t{$i}", 'ip' => '127.0.0.1', 'created_at' => now()->subMinutes($i)]); + } + + Livewire::test(Index::class) + ->assertViewHas('events', fn ($e) => $e->total() === 30 && $e->count() === 25 && $e->currentPage() === 1) + ->call('gotoPage', 2) + ->assertViewHas('events', fn ($e) => $e->currentPage() === 2 && $e->count() === 5); + } + + public function test_changing_the_search_resets_to_the_first_page(): void + { + for ($i = 0; $i < 30; $i++) { + AuditEvent::create(['actor' => 'A', 'action' => 'user.create', 'target' => "t{$i}", 'ip' => '127.0.0.1', 'created_at' => now()->subMinutes($i)]); + } + + Livewire::test(Index::class) + ->call('gotoPage', 2) + ->set('q', 'user') + ->assertViewHas('events', fn ($e) => $e->currentPage() === 1); + } + + public function test_search_filters_in_sql_across_all_pages_not_just_one(): void + { + // 25 filler (fills page 1) + one older match that would otherwise sit on page 2 unfiltered. + for ($i = 0; $i < 25; $i++) { + AuditEvent::create(['actor' => 'A', 'action' => 'user.create', 'target' => "t{$i}", 'ip' => '127.0.0.1', 'created_at' => now()->subMinutes($i)]); + } + AuditEvent::create(['actor' => 'A', 'action' => 'wg.set-endpoint', 'target' => 'x', 'ip' => '127.0.0.1', 'created_at' => now()->subMinutes(99)]); + + Livewire::test(Index::class) + ->set('q', 'Endpoint geändert') // label search → SQL-matches the single entry, now on page 1 + ->assertViewHas('events', fn ($e) => $e->total() === 1) + ->assertSee('WireGuard-Endpoint geändert'); + } }