TenantAtlas/app/Filament/Resources/BulkOperationRunResource.php

389 lines
17 KiB
PHP

<?php
namespace App\Filament\Resources;
use App\Filament\Resources\BulkOperationRunResource\Pages;
use App\Models\BulkOperationRun;
use App\Models\Tenant;
use App\Support\OperationRunLinks;
use BackedEnum;
use Filament\Actions;
use Filament\Forms\Components\DatePicker;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use UnitEnum;
class BulkOperationRunResource extends Resource
{
protected static bool $isScopedToTenant = false;
protected static ?string $model = BulkOperationRun::class;
protected static bool $shouldRegisterNavigation = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clock';
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
protected static ?string $navigationLabel = 'Operations';
public static function form(Schema $schema): Schema
{
return $schema;
}
public static function infolist(Schema $schema): Schema
{
return $schema
->schema([
Section::make('Legacy run view')
->description('Canonical monitoring is now available in Monitoring → Operations.')
->schema([
TextEntry::make('canonical_view')
->label('Canonical view')
->state('View in Operations')
->url(fn (BulkOperationRun $record): string => OperationRunLinks::index(Tenant::current() ?? $record->tenant))
->badge()
->color('primary'),
])
->columnSpanFull(),
Section::make('Run')
->schema([
TextEntry::make('user.name')
->label('Initiator')
->placeholder('—'),
TextEntry::make('resource')->badge(),
TextEntry::make('action')->badge(),
TextEntry::make('status')
->label('Outcome')
->badge()
->state(fn (BulkOperationRun $record): string => $record->statusBucket())
->color(fn (BulkOperationRun $record): string => static::statusBucketColor($record->statusBucket())),
TextEntry::make('total_items')->label('Total')->numeric(),
TextEntry::make('processed_items')->label('Processed')->numeric(),
TextEntry::make('succeeded')->numeric(),
TextEntry::make('failed')->numeric(),
TextEntry::make('skipped')->numeric(),
TextEntry::make('created_at')->dateTime(),
TextEntry::make('updated_at')->dateTime(),
TextEntry::make('idempotency_key')->label('Idempotency key')->copyable()->placeholder('—'),
])
->columns(2)
->columnSpanFull(),
Section::make('Related')
->schema([
TextEntry::make('related_backup_set')
->label('Backup set')
->state(function (BulkOperationRun $record): ?string {
$backupSetId = static::backupSetIdFromItemIds($record);
if (! $backupSetId) {
return null;
}
return "#{$backupSetId}";
})
->url(function (BulkOperationRun $record): ?string {
$backupSetId = static::backupSetIdFromItemIds($record);
if (! $backupSetId) {
return null;
}
return BackupSetResource::getUrl('view', ['record' => $backupSetId], tenant: Tenant::current());
})
->visible(fn (BulkOperationRun $record): bool => static::backupSetIdFromItemIds($record) !== null)
->placeholder('—')
->columnSpanFull(),
TextEntry::make('related_drift_findings')
->label('Drift findings')
->state('View')
->url(function (BulkOperationRun $record): ?string {
if ($record->runType() !== 'drift.generate') {
return null;
}
$payload = $record->item_ids ?? [];
if (! is_array($payload)) {
return FindingResource::getUrl('index', tenant: Tenant::current());
}
$scopeKey = null;
$baselineRunId = null;
$currentRunId = null;
if (array_is_list($payload) && isset($payload[0]) && is_string($payload[0])) {
$scopeKey = $payload[0];
} else {
$scopeKey = is_string($payload['scope_key'] ?? null) ? $payload['scope_key'] : null;
if (is_numeric($payload['baseline_run_id'] ?? null)) {
$baselineRunId = (int) $payload['baseline_run_id'];
}
if (is_numeric($payload['current_run_id'] ?? null)) {
$currentRunId = (int) $payload['current_run_id'];
}
}
$tableFilters = [];
if (is_string($scopeKey) && $scopeKey !== '') {
$tableFilters['scope_key'] = ['scope_key' => $scopeKey];
}
if (is_int($baselineRunId) || is_int($currentRunId)) {
$tableFilters['run_ids'] = [
'baseline_run_id' => $baselineRunId,
'current_run_id' => $currentRunId,
];
}
$parameters = $tableFilters !== [] ? ['tableFilters' => $tableFilters] : [];
return FindingResource::getUrl('index', $parameters, tenant: Tenant::current());
})
->visible(fn (BulkOperationRun $record): bool => $record->runType() === 'drift.generate')
->placeholder('—')
->columnSpanFull(),
])
->visible(fn (BulkOperationRun $record): bool => in_array($record->runType(), ['backup_set.add_policies', 'drift.generate'], true))
->columnSpanFull(),
Section::make('Items')
->schema([
ViewEntry::make('item_ids')
->label('')
->view('filament.infolists.entries.snapshot-json')
->state(fn (BulkOperationRun $record) => $record->item_ids ?? [])
->columnSpanFull(),
])
->columnSpanFull(),
Section::make('Failures')
->schema([
ViewEntry::make('failures')
->label('')
->view('filament.infolists.entries.snapshot-json')
->state(fn (BulkOperationRun $record) => $record->failures ?? [])
->columnSpanFull(),
])
->columnSpanFull(),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('id', 'desc')
->modifyQueryUsing(function (Builder $query): Builder {
$tenantId = Tenant::current()->getKey();
return $query->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId));
})
->columns([
Tables\Columns\TextColumn::make('user.name')
->label('Initiator')
->placeholder('—')
->toggleable(),
Tables\Columns\TextColumn::make('resource')->badge(),
Tables\Columns\TextColumn::make('action')->badge(),
Tables\Columns\TextColumn::make('status')
->label('Outcome')
->badge()
->formatStateUsing(fn (BulkOperationRun $record): string => $record->statusBucket())
->color(fn (BulkOperationRun $record): string => static::statusBucketColor($record->statusBucket())),
Tables\Columns\TextColumn::make('created_at')->since(),
Tables\Columns\TextColumn::make('total_items')->label('Total')->numeric(),
Tables\Columns\TextColumn::make('processed_items')->label('Processed')->numeric(),
Tables\Columns\TextColumn::make('failed')->numeric(),
])
->filters([
Tables\Filters\SelectFilter::make('run_type')
->label('Run type')
->options(fn (): array => static::runTypeOptions())
->query(function (Builder $query, array $data): Builder {
$value = $data['value'] ?? null;
if (! is_string($value) || $value === '' || ! str_contains($value, '.')) {
return $query;
}
[$resource, $action] = explode('.', $value, 2);
if ($resource === '' || $action === '') {
return $query;
}
return $query
->where('resource', $resource)
->where('action', $action);
}),
Tables\Filters\SelectFilter::make('status_bucket')
->label('Status')
->options([
'queued' => 'Queued',
'running' => 'Running',
'succeeded' => 'Succeeded',
'partially succeeded' => 'Partially succeeded',
'failed' => 'Failed',
])
->query(function (Builder $query, array $data): Builder {
$value = $data['value'] ?? null;
if (! is_string($value) || $value === '') {
return $query;
}
$nonSkippedFailureSql = "EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(failures, '[]'::jsonb)) AS elem WHERE (elem->>'type' IS NULL OR elem->>'type' <> 'skipped'))";
return match ($value) {
'queued' => $query->where('status', 'pending'),
'running' => $query->where('status', 'running'),
'succeeded' => $query
->whereIn('status', ['completed', 'completed_with_errors'])
->where('failed', 0)
->whereRaw("NOT {$nonSkippedFailureSql}"),
'partially succeeded' => $query
->whereNotIn('status', ['pending', 'running'])
->where('succeeded', '>', 0)
->where(function (Builder $q) use ($nonSkippedFailureSql): void {
$q->where('failed', '>', 0)->orWhereRaw($nonSkippedFailureSql);
}),
'failed' => $query
->whereNotIn('status', ['pending', 'running'])
->where(function (Builder $q) use ($nonSkippedFailureSql): void {
$q->where(function (Builder $q) use ($nonSkippedFailureSql): void {
$q->where('succeeded', 0)
->where(function (Builder $q) use ($nonSkippedFailureSql): void {
$q->where('failed', '>', 0)->orWhereRaw($nonSkippedFailureSql);
});
})->orWhere(function (Builder $q) use ($nonSkippedFailureSql): void {
$q->whereIn('status', ['failed', 'aborted'])
->whereNot(function (Builder $q) use ($nonSkippedFailureSql): void {
$q->where('succeeded', '>', 0)
->where(function (Builder $q) use ($nonSkippedFailureSql): void {
$q->where('failed', '>', 0)->orWhereRaw($nonSkippedFailureSql);
});
});
});
}),
default => $query,
};
}),
Tables\Filters\Filter::make('created_at')
->label('Created')
->form([
DatePicker::make('created_from')
->label('From')
->default(fn () => now()->subDays(30)),
DatePicker::make('created_until')
->label('Until')
->default(fn () => now()),
])
->query(function (Builder $query, array $data): Builder {
$from = $data['created_from'] ?? null;
if ($from) {
$query->whereDate('created_at', '>=', $from);
}
$until = $data['created_until'] ?? null;
if ($until) {
$query->whereDate('created_at', '<=', $until);
}
return $query;
}),
])
->actions([
Actions\ViewAction::make(),
])
->bulkActions([]);
}
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->with('user')
->latest('id');
}
public static function getPages(): array
{
return [
'index' => Pages\ListBulkOperationRuns::route('/'),
'view' => Pages\ViewBulkOperationRun::route('/{record}'),
];
}
/**
* @return array<string, string>
*/
private static function runTypeOptions(): array
{
$tenantId = Tenant::current()->getKey();
$knownTypes = [
'drift.generate' => 'drift.generate',
'backup_set.add_policies' => 'backup_set.add_policies',
];
$storedTypes = BulkOperationRun::query()
->where('tenant_id', $tenantId)
->select(['resource', 'action'])
->distinct()
->orderBy('resource')
->orderBy('action')
->get()
->mapWithKeys(function (BulkOperationRun $run): array {
$type = "{$run->resource}.{$run->action}";
return [$type => $type];
})
->all();
return array_replace($storedTypes, $knownTypes);
}
private static function statusBucketColor(string $statusBucket): string
{
return match ($statusBucket) {
'succeeded' => 'success',
'partially succeeded' => 'warning',
'failed' => 'danger',
'running' => 'info',
'queued' => 'gray',
default => 'gray',
};
}
private static function backupSetIdFromItemIds(BulkOperationRun $record): ?int
{
if ($record->runType() !== 'backup_set.add_policies') {
return null;
}
$payload = $record->item_ids ?? [];
if (! is_array($payload)) {
return null;
}
$backupSetId = $payload['backup_set_id'] ?? null;
if (! is_numeric($backupSetId)) {
return null;
}
$backupSetId = (int) $backupSetId;
return $backupSetId > 0 ? $backupSetId : null;
}
}