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:
231
src/app/Filament/Resources/AuditResource.php
Normal file
231
src/app/Filament/Resources/AuditResource.php
Normal 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}'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/app/Filament/Resources/AuditResource/Pages/ViewAudit.php
Normal file
16
src/app/Filament/Resources/AuditResource/Pages/ViewAudit.php
Normal 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
111
src/app/Models/Audit.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,8 +12,20 @@
|
|||||||
public function up(): void
|
public function up(): void
|
||||||
{
|
{
|
||||||
Schema::create('audits', function (Blueprint $table) {
|
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->timestamps();
|
||||||
|
|
||||||
|
$table->index(['user_id', 'user_type']);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ public function run(): void
|
|||||||
'users.edit',
|
'users.edit',
|
||||||
'users.delete',
|
'users.delete',
|
||||||
'settings.manage',
|
'settings.manage',
|
||||||
|
'audit.view',
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($corePermissions as $permission) {
|
foreach ($corePermissions as $permission) {
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user