clusev/app/Livewire/Audit/Index.php

150 lines
4.8 KiB
PHP

<?php
namespace App\Livewire\Audit;
use App\Models\AuditEvent;
use App\Models\Setting;
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 = '';
/**
* Aufbewahrungsdauer in Tagen (Formularfeld). Leer/0 = unbegrenzt; ein
* positiver Wert N löscht beim täglichen Prune Einträge älter als N Tage.
*/
public ?string $retentionDays = null;
public function mount(): void
{
// Empty/0 means "keep forever" — surface that as a blank field, not "0".
$stored = (int) Setting::get('audit_retention_days', '0');
$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
{
$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<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' => $events,
'pageWindow' => $this->pageWindow($events->currentPage(), $events->lastPage()),
'retentionPolicy' => $this->retentionPolicy(),
])->title(__('audit.title'));
}
}