Add central Audit Trail viewer in Filament admin

Features:
- AuditResource with table listing all audit logs
- ViewAudit page showing detailed changes with diff table
- Filters: date range, user, model type, event type
- Search by user name
- Auto-refresh every 60 seconds
- Dark mode support for changes view
- Fixed audits migration with proper schema
- Added audit.view permission (admin only by default)

Files:
- src/app/Models/Audit.php
- src/app/Filament/Resources/AuditResource.php
- src/app/Filament/Resources/AuditResource/Pages/
- src/resources/views/filament/infolists/entries/audit-changes.blade.php
- Updated audits migration with full schema
This commit is contained in:
2026-03-11 09:10:38 +02:00
parent f903914c2b
commit e380721938
7 changed files with 451 additions and 1 deletions

View File

@@ -0,0 +1,231 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\AuditResource\Pages;
use App\Models\Audit;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Infolists;
use Filament\Infolists\Infolist;
use Illuminate\Database\Eloquent\Builder;
class AuditResource extends Resource
{
protected static ?string $model = Audit::class;
protected static ?string $navigationIcon = 'heroicon-o-clipboard-document-list';
protected static ?string $navigationGroup = 'System';
protected static ?string $navigationLabel = 'Audit Trail';
protected static ?int $navigationSort = 100;
protected static ?string $modelLabel = 'Audit Log';
protected static ?string $pluralModelLabel = 'Audit Trail';
public static function canAccess(): bool
{
return auth()->user()?->can('audit.view') ?? false;
}
public static function canCreate(): bool
{
return false;
}
public static function canEdit($record): bool
{
return false;
}
public static function canDelete($record): bool
{
return false;
}
public static function form(Form $form): Form
{
return $form->schema([]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('created_at')
->label('Date/Time')
->dateTime('M j, Y H:i:s')
->sortable(),
Tables\Columns\TextColumn::make('user.name')
->label('User')
->default('System')
->searchable()
->sortable(),
Tables\Columns\BadgeColumn::make('event')
->label('Action')
->colors([
'success' => 'created',
'warning' => 'updated',
'danger' => 'deleted',
'info' => 'restored',
])
->formatStateUsing(fn (string $state): string => ucfirst($state)),
Tables\Columns\TextColumn::make('auditable_type')
->label('Model')
->formatStateUsing(fn (string $state): string => class_basename($state))
->sortable(),
Tables\Columns\TextColumn::make('auditable_id')
->label('Record ID')
->sortable(),
Tables\Columns\TextColumn::make('ip_address')
->label('IP Address')
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('url')
->label('URL')
->limit(30)
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('created_at', 'desc')
->filters([
Tables\Filters\SelectFilter::make('event')
->label('Action')
->options([
'created' => 'Created',
'updated' => 'Updated',
'deleted' => 'Deleted',
'restored' => 'Restored',
]),
Tables\Filters\SelectFilter::make('user_id')
->label('User')
->relationship('user', 'name')
->searchable()
->preload(),
Tables\Filters\SelectFilter::make('auditable_type')
->label('Model')
->options(fn () => Audit::query()
->distinct()
->pluck('auditable_type')
->mapWithKeys(fn ($type) => [$type => class_basename($type)])
->toArray()
),
Tables\Filters\Filter::make('created_at')
->form([
Forms\Components\DatePicker::make('from')
->label('From Date'),
Forms\Components\DatePicker::make('until')
->label('Until Date'),
])
->query(function (Builder $query, array $data): Builder {
return $query
->when(
$data['from'],
fn (Builder $query, $date): Builder => $query->whereDate('created_at', '>=', $date),
)
->when(
$data['until'],
fn (Builder $query, $date): Builder => $query->whereDate('created_at', '<=', $date),
);
})
->indicateUsing(function (array $data): array {
$indicators = [];
if ($data['from'] ?? null) {
$indicators['from'] = 'From: ' . $data['from'];
}
if ($data['until'] ?? null) {
$indicators['until'] = 'Until: ' . $data['until'];
}
return $indicators;
}),
])
->actions([
Tables\Actions\ViewAction::make(),
])
->bulkActions([])
->poll('60s');
}
public static function infolist(Infolist $infolist): Infolist
{
return $infolist
->schema([
Infolists\Components\Section::make('Audit Details')
->schema([
Infolists\Components\Grid::make(3)
->schema([
Infolists\Components\TextEntry::make('created_at')
->label('Date/Time')
->dateTime('F j, Y H:i:s'),
Infolists\Components\TextEntry::make('user.name')
->label('User')
->default('System'),
Infolists\Components\TextEntry::make('event')
->label('Action')
->badge()
->color(fn (string $state): string => match ($state) {
'created' => 'success',
'updated' => 'warning',
'deleted' => 'danger',
'restored' => 'info',
default => 'gray',
})
->formatStateUsing(fn (string $state): string => ucfirst($state)),
]),
Infolists\Components\Grid::make(3)
->schema([
Infolists\Components\TextEntry::make('auditable_type')
->label('Model')
->formatStateUsing(fn (string $state): string => class_basename($state)),
Infolists\Components\TextEntry::make('auditable_id')
->label('Record ID'),
Infolists\Components\TextEntry::make('ip_address')
->label('IP Address')
->default('N/A'),
]),
Infolists\Components\TextEntry::make('url')
->label('URL')
->columnSpanFull(),
]),
Infolists\Components\Section::make('Changes')
->schema([
Infolists\Components\ViewEntry::make('changes')
->view('filament.infolists.entries.audit-changes')
->columnSpanFull(),
]),
]);
}
public static function getRelations(): array
{
return [];
}
public static function getPages(): array
{
return [
'index' => Pages\ListAudits::route('/'),
'view' => Pages\ViewAudit::route('/{record}'),
];
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Resources\AuditResource\Pages;
use App\Filament\Resources\AuditResource;
use Filament\Resources\Pages\ListRecords;
class ListAudits extends ListRecords
{
protected static string $resource = AuditResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Resources\AuditResource\Pages;
use App\Filament\Resources\AuditResource;
use Filament\Resources\Pages\ViewRecord;
class ViewAudit extends ViewRecord
{
protected static string $resource = AuditResource::class;
protected function getHeaderActions(): array
{
return [];
}
}