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 [];
}
}

111
src/app/Models/Audit.php Normal file
View File

@@ -0,0 +1,111 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class Audit extends Model
{
protected $table = 'audits';
protected $guarded = [];
protected $casts = [
'old_values' => 'array',
'new_values' => 'array',
];
/**
* Get the user that performed the action.
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* Get the auditable model.
*/
public function auditable(): MorphTo
{
return $this->morphTo();
}
/**
* Get the event badge color.
*/
public function getEventColorAttribute(): string
{
return match ($this->event) {
'created' => 'success',
'updated' => 'warning',
'deleted' => 'danger',
'restored' => 'info',
default => 'gray',
};
}
/**
* Get a human-readable model name.
*/
public function getModelNameAttribute(): string
{
$class = class_basename($this->auditable_type);
return preg_replace('/(?<!^)[A-Z]/', ' $0', $class);
}
/**
* Get formatted changes for display.
*/
public function getFormattedChangesAttribute(): array
{
$changes = [];
$oldValues = $this->old_values ?? [];
$newValues = $this->new_values ?? [];
$allKeys = array_unique(array_merge(array_keys($oldValues), array_keys($newValues)));
foreach ($allKeys as $key) {
$old = $oldValues[$key] ?? null;
$new = $newValues[$key] ?? null;
if ($old !== $new) {
$changes[] = [
'field' => $key,
'old' => $this->formatValue($old),
'new' => $this->formatValue($new),
];
}
}
return $changes;
}
/**
* Format a value for display.
*/
protected function formatValue($value): string
{
if (is_null($value)) {
return '(empty)';
}
if (is_bool($value)) {
return $value ? 'Yes' : 'No';
}
if (is_array($value)) {
return json_encode($value);
}
$stringValue = (string) $value;
if (strlen($stringValue) > 100) {
return substr($stringValue, 0, 100) . '...';
}
return $stringValue;
}
}

View File

@@ -12,8 +12,20 @@
public function up(): void
{
Schema::create('audits', function (Blueprint $table) {
$table->id();
$table->bigIncrements('id');
$table->string('user_type')->nullable();
$table->unsignedBigInteger('user_id')->nullable();
$table->string('event');
$table->morphs('auditable');
$table->text('old_values')->nullable();
$table->text('new_values')->nullable();
$table->text('url')->nullable();
$table->ipAddress('ip_address')->nullable();
$table->string('user_agent', 1023)->nullable();
$table->string('tags')->nullable();
$table->timestamps();
$table->index(['user_id', 'user_type']);
});
}

View File

@@ -21,6 +21,7 @@ public function run(): void
'users.edit',
'users.delete',
'settings.manage',
'audit.view',
];
foreach ($corePermissions as $permission) {

View File

@@ -0,0 +1,63 @@
<div class="space-y-4">
@php
$oldValues = $getRecord()->old_values ?? [];
$newValues = $getRecord()->new_values ?? [];
$allKeys = array_unique(array_merge(array_keys($oldValues), array_keys($newValues)));
@endphp
@if(count($allKeys) === 0)
<p class="text-sm text-gray-500 dark:text-gray-400 italic">No changes recorded.</p>
@else
<div class="overflow-x-auto">
<table class="w-full text-sm border-collapse">
<thead>
<tr class="bg-gray-50 dark:bg-gray-800">
<th class="px-4 py-2 text-left font-medium text-gray-600 dark:text-gray-300 border dark:border-gray-700">Field</th>
<th class="px-4 py-2 text-left font-medium text-gray-600 dark:text-gray-300 border dark:border-gray-700">Old Value</th>
<th class="px-4 py-2 text-left font-medium text-gray-600 dark:text-gray-300 border dark:border-gray-700">New Value</th>
</tr>
</thead>
<tbody>
@foreach($allKeys as $key)
@php
$oldValue = $oldValues[$key] ?? null;
$newValue = $newValues[$key] ?? null;
$hasChanged = $oldValue !== $newValue;
@endphp
<tr class="{{ $hasChanged ? 'bg-yellow-50 dark:bg-yellow-900/20' : '' }}">
<td class="px-4 py-2 font-medium text-gray-700 dark:text-gray-200 border dark:border-gray-700">
{{ \Illuminate\Support\Str::headline($key) }}
</td>
<td class="px-4 py-2 text-gray-600 dark:text-gray-300 border dark:border-gray-700">
@if(is_null($oldValue))
<span class="text-gray-400 italic">(empty)</span>
@elseif(is_array($oldValue))
<code class="text-xs bg-gray-100 dark:bg-gray-700 px-1 py-0.5 rounded">{{ json_encode($oldValue) }}</code>
@elseif(is_bool($oldValue))
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {{ $oldValue ? 'bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100' : 'bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100' }}">
{{ $oldValue ? 'Yes' : 'No' }}
</span>
@else
{{ \Illuminate\Support\Str::limit((string) $oldValue, 100) }}
@endif
</td>
<td class="px-4 py-2 text-gray-600 dark:text-gray-300 border dark:border-gray-700">
@if(is_null($newValue))
<span class="text-gray-400 italic">(empty)</span>
@elseif(is_array($newValue))
<code class="text-xs bg-gray-100 dark:bg-gray-700 px-1 py-0.5 rounded">{{ json_encode($newValue) }}</code>
@elseif(is_bool($newValue))
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {{ $newValue ? 'bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100' : 'bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100' }}">
{{ $newValue ? 'Yes' : 'No' }}
</span>
@else
{{ \Illuminate\Support\Str::limit((string) $newValue, 100) }}
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>