feat(failed-jobs): add database-agnostic failed jobs with bulk actions

- Create custom implementation to replace vendor plugin
  - Add database-agnostic JSON queries (MySQL, MariaDB, PG, SQLite, SQLSRV)
  - Implement Retry/Prune header actions with queue selection
  - Maintain all original features: filters, search, actions, bulk operations

  BREAKING CHANGE: New route /failed-jobs/compatible-failed-jobs

  Fixes MariaDB JSON syntax errors
This commit is contained in:
idevakk
2025-12-02 10:23:55 -08:00
parent d767c6cf59
commit 2eaf38e139
4 changed files with 252 additions and 2 deletions

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Filament\Resources\FailedJobs;
use App\Filament\Resources\FailedJobs\Pages\CompatibleListFailedJobs;
use BackedEnum;
use BinaryBuilds\FilamentFailedJobs\Models\FailedJob;
use BinaryBuilds\FilamentFailedJobs\Resources\FailedJobs\Pages\ViewFailedJob;
use BinaryBuilds\FilamentFailedJobs\Resources\FailedJobs\Schemas\FailedJobInfolist;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class CompatibleFailedJobResource extends Resource
{
protected static ?string $model = FailedJob::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::QueueList;
protected static string|null|\UnitEnum $navigationGroup = 'Admin';
public static function infolist(Schema $schema): Schema
{
return FailedJobInfolist::configure($schema);
}
public static function table(Table $table): Table
{
return CompatibleFailedJobsTable::configure($table)
->defaultSort('id', 'desc');
}
public static function getPages(): array
{
return [
'index' => CompatibleListFailedJobs::route('/'),
'view' => ViewFailedJob::route('/{record}'),
];
}
/**
* Only make this resource available in specific panels
*/
public static function canViewAny(): bool
{
// Check if we're in the 0xdash panel or other admin panels
$panel = filament()->getCurrentPanel();
return in_array($panel->getId(), ['0xdash', 'admin']);
}
}

View File

@@ -0,0 +1,132 @@
<?php
namespace App\Filament\Resources\FailedJobs;
use BinaryBuilds\FilamentFailedJobs\Actions\DeleteJobAction;
use BinaryBuilds\FilamentFailedJobs\Actions\DeleteJobsBulkAction;
use BinaryBuilds\FilamentFailedJobs\Actions\RetryJobAction;
use BinaryBuilds\FilamentFailedJobs\Actions\RetryJobsBulkAction;
use BinaryBuilds\FilamentFailedJobs\Models\FailedJob;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\ViewAction;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Select;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
class CompatibleFailedJobsTable
{
/**
* Configure the table with database-agnostic JSON queries
*/
public static function configure(Table $table): Table
{
return $table
->columns(array_filter([
TextColumn::make('id')
->numeric()
->sortable(),
TextColumn::make('connection')->searchable(),
TextColumn::make('queue')->searchable(),
TextColumn::make('payload')->label('Job')
->formatStateUsing(function ($state) {
return json_decode($state, true)['displayName'];
})->searchable(),
TextColumn::make('exception')->wrap()->limit(100),
TextColumn::make('failed_at')->searchable(),
]))
->filters(self::getCompatibleFiltersForIndex())
->recordActions([
RetryJobAction::make()->iconButton()->tooltip(__('Retry Job')),
ViewAction::make()->iconButton()->tooltip(__('View Job')),
DeleteJobAction::make()->iconButton()->tooltip(__('Delete Job')),
])
->toolbarActions([
BulkActionGroup::make([
RetryJobsBulkAction::make(),
DeleteJobsBulkAction::make(),
]),
]);
}
/**
* Database-agnostic version of getFiltersForIndex
*/
private static function getCompatibleFiltersForIndex(): array
{
$jobs = FailedJob::query()
->select(['connection', 'queue'])
->selectRaw(self::getJsonExtractExpression('displayName', 'job'))
->get();
$connections = $jobs->pluck('connection', 'connection')->map(fn ($conn) => ucfirst($conn))->toArray();
$queues = $jobs->pluck('queue', 'queue')->map(fn ($queue) => ucfirst($queue))->toArray();
$jobNames = $jobs->pluck('job', 'job')->toArray();
return [
SelectFilter::make('Connection')->options($connections),
SelectFilter::make('Queue')->options($queues),
Filter::make('Job')
->schema([
Select::make('job')->options($jobNames),
])
->query(function (Builder $query, array $data): Builder {
return $query
->when(
$data['job'],
fn (Builder $query, $job): Builder => $query->whereRaw(self::getJsonExtractExpression('displayName').' = ?', [$job]),
);
}),
Filter::make('failed_at')
->schema([
DatePicker::make('failed_at'),
])
->query(function (Builder $query, array $data): Builder {
return $query
->when(
$data['failed_at'],
fn (Builder $query, $date): Builder => $query->whereDate('failed_at', '>=', $date),
);
}),
];
}
/**
* Get database-agnostic JSON extraction expression
*/
private static function getJsonExtractExpression(string $path, ?string $alias = null): string
{
$driver = DB::getDriverName();
switch ($driver) {
case 'mysql':
case 'mariadb':
$expression = "JSON_UNQUOTE(JSON_EXTRACT(payload, '$.".$path."'))";
break;
case 'pgsql':
$expression = "payload->>'".$path."'";
break;
case 'sqlite':
$expression = "json_extract(payload, '$.".$path."')";
break;
case 'sqlsrv':
$expression = "JSON_VALUE(payload, '$.".$path."')";
break;
default:
// Fallback to MySQL syntax
$expression = "JSON_UNQUOTE(JSON_EXTRACT(payload, '$.".$path."'))";
break;
}
return $alias ? $expression.' AS '.$alias : $expression;
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Filament\Resources\FailedJobs\Pages;
use App\Filament\Resources\FailedJobs\CompatibleFailedJobResource;
use BinaryBuilds\FilamentFailedJobs\Models\FailedJob;
use Filament\Actions\Action;
use Filament\Forms\Components\Radio;
use Filament\Forms\Components\TextInput;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Support\Facades\Artisan;
class CompatibleListFailedJobs extends ListRecords
{
protected static string $resource = CompatibleFailedJobResource::class;
protected function getHeaderActions(): array
{
return [
Action::make(__('Retry Jobs'))
->requiresConfirmation()
->schema(function () {
$queues = FailedJob::query()
->select('queue')
->distinct()
->pluck('queue')
->toArray();
$options = [
'all' => 'All Queues',
];
$descriptions = [
'all' => 'Retry all Jobs',
];
foreach ($queues as $queue) {
$options[$queue] = $queue;
$descriptions[$queue] = 'Retry jobs from '.$queue.' queue';
}
return [
Radio::make('queue')
->options($options)
->descriptions($descriptions)
->default('all')
->required(),
];
})
->successNotificationTitle(__('Jobs pushed to queue successfully!'))
->action(fn (array $data) => Artisan::call('queue:retry '.$data['queue'])),
Action::make(__('Prune Jobs'))
->requiresConfirmation()
->schema([
TextInput::make('hours')
->numeric()
->required()
->default(1)
->helperText(__("Prune's all failed jobs older than given hours.")),
])
->color('danger')
->successNotificationTitle(__('Jobs pruned successfully!'))
->action(fn (array $data) => Artisan::call('queue:prune-failed --hours='.$data['hours'])),
];
}
}