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 { $validated = $this->validate([ 'retentionDays' => ['nullable', 'integer', 'min:1', 'max:3650'], ]); $days = $validated['retentionDays'] !== null && $validated['retentionDays'] !== '' ? (int) $validated['retentionDays'] : 0; Setting::put('audit_retention_days', $days > 0 ? (string) $days : null); $this->retentionDays = $days > 0 ? (string) $days : null; AuditEvent::create([ 'user_id' => Auth::id(), 'actor' => Auth::user()?->name ?? 'system', 'action' => 'audit.retention_set', 'target' => 'audit_retention_days='.$days, 'ip' => request()->ip(), ]); $this->dispatch('notify', message: __('audit.retention_saved_notify')); } /** Effective retention in days (0 = unlimited) for the policy summary. */ protected function retentionPolicy(): int { return $this->retentionDays !== null && $this->retentionDays !== '' ? (int) $this->retentionDays : 0; } /** * 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 eventsQuery(): Builder { $query = AuditEvent::with('server')->latest(); $needle = trim($this->q); if ($needle === '') { return $query; } $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 $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); } }); } /** * 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' => $events, 'pageWindow' => $this->pageWindow($events->currentPage(), $events->lastPage()), 'retentionPolicy' => $this->retentionPolicy(), ])->title(__('audit.title')); } }