feat(audit): paginate the log + DB-wide search (v0.9.50)
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 <noreply@anthropic.com>feat/v1-foundation v0.9.50
parent
6d9dcb9529
commit
c6597be59e
11
CHANGELOG.md
11
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
|
||||
|
|
|
|||
|
|
@ -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<int, int|string>
|
||||
*/
|
||||
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'));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 …',
|
||||
|
||||
|
|
|
|||
|
|
@ -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 …',
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('audit.eyebrow') }}</p>
|
||||
<h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">{{ __('audit.heading') }}</h2>
|
||||
</div>
|
||||
<x-status-pill status="online">{{ __('audit.event_count', ['count' => $events->count()]) }}</x-status-pill>
|
||||
<x-status-pill status="online">{{ __('audit.event_count', ['count' => $events->total()]) }}</x-status-pill>
|
||||
</div>
|
||||
|
||||
{{-- Aufbewahrung (Retention) --}}
|
||||
|
|
@ -116,5 +116,38 @@
|
|||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
{{-- Pagination — page is deep-linked via ?page= (Livewire WithPagination). --}}
|
||||
@if ($events->lastPage() > 1)
|
||||
<nav aria-label="{{ __('audit.pagination_nav') }}" class="flex items-center justify-between gap-3 border-t border-line px-4 py-3 sm:px-5">
|
||||
<button type="button" wire:click="previousPage" @disabled($events->onFirstPage())
|
||||
class="inline-flex items-center gap-1 rounded-md border border-line bg-raised px-2.5 py-3 font-mono text-[11px] text-ink-2 transition-colors hover:border-accent/30 hover:text-ink disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:border-line disabled:hover:text-ink-2 lg:py-1.5">
|
||||
<x-icon name="chevron-left" class="h-3.5 w-3.5" />
|
||||
<span class="hidden sm:inline">{{ __('audit.pagination_prev') }}</span>
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
@foreach ($pageWindow as $p)
|
||||
@if ($p === '…')
|
||||
<span class="px-1 font-mono text-[11px] text-ink-4" aria-hidden="true">…</span>
|
||||
@else
|
||||
<button type="button" wire:key="audit-page-{{ $p }}" wire:click="gotoPage({{ $p }})"
|
||||
@class([
|
||||
'grid h-11 min-w-11 place-items-center rounded-md border px-2 font-mono text-[11px] tabular-nums transition-colors lg:h-7 lg:min-w-[28px]',
|
||||
'border-accent/40 bg-accent/15 text-accent-text' => $p === $events->currentPage(),
|
||||
'border-line bg-raised text-ink-2 hover:border-accent/30 hover:text-ink' => $p !== $events->currentPage(),
|
||||
])
|
||||
@if ($p === $events->currentPage()) aria-current="page" @endif>{{ $p }}</button>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<button type="button" wire:click="nextPage" @disabled(! $events->hasMorePages())
|
||||
class="inline-flex items-center gap-1 rounded-md border border-line bg-raised px-2.5 py-3 font-mono text-[11px] text-ink-2 transition-colors hover:border-accent/30 hover:text-ink disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:border-line disabled:hover:text-ink-2 lg:py-1.5">
|
||||
<span class="hidden sm:inline">{{ __('audit.pagination_next') }}</span>
|
||||
<x-icon name="chevron-right" class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</nav>
|
||||
@endif
|
||||
</x-panel>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue