Status Update Committed the async “Add selected” flow: job-only handler, deterministic run reuse, sanitized failure tracking, observation updates, and the new BulkOperationService/Progress test coverage. All relevant tasks in tasks.md are marked done, and the checklist under requirements.md is fully satisfied (PASS). Ran ./vendor/bin/pint --dirty plus BackupSetPolicyPickerTableTest.php—all green. Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local> Reviewed-on: #59
423 lines
17 KiB
PHP
423 lines
17 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire;
|
|
|
|
use App\Filament\Resources\BulkOperationRunResource;
|
|
use App\Jobs\AddPoliciesToBackupSetJob;
|
|
use App\Models\BackupSet;
|
|
use App\Models\BulkOperationRun;
|
|
use App\Models\Policy;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Services\BulkOperationService;
|
|
use App\Support\RunIdempotency;
|
|
use Filament\Actions\BulkAction;
|
|
use Filament\Notifications\Notification;
|
|
use Filament\Tables\Columns\TextColumn;
|
|
use Filament\Tables\Filters\SelectFilter;
|
|
use Filament\Tables\Filters\TernaryFilter;
|
|
use Filament\Tables\Table;
|
|
use Filament\Tables\TableComponent;
|
|
use Illuminate\Contracts\View\View;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Database\QueryException;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Str;
|
|
|
|
class BackupSetPolicyPickerTable extends TableComponent
|
|
{
|
|
public int $backupSetId;
|
|
|
|
public bool $include_assignments = true;
|
|
|
|
public bool $include_scope_tags = true;
|
|
|
|
public bool $include_foundations = true;
|
|
|
|
public function mount(int $backupSetId): void
|
|
{
|
|
$this->backupSetId = $backupSetId;
|
|
}
|
|
|
|
public static function externalIdShort(?string $externalId): string
|
|
{
|
|
$value = (string) ($externalId ?? '');
|
|
|
|
$normalized = preg_replace('/[^A-Za-z0-9]/', '', $value) ?? '';
|
|
|
|
if ($normalized === '') {
|
|
return '—';
|
|
}
|
|
|
|
return substr($normalized, -8);
|
|
}
|
|
|
|
public function table(Table $table): Table
|
|
{
|
|
$backupSet = BackupSet::query()->find($this->backupSetId);
|
|
$tenantId = $backupSet?->tenant_id ?? Tenant::current()->getKey();
|
|
$existingPolicyIds = $backupSet
|
|
? $backupSet->items()->pluck('policy_id')->filter()->all()
|
|
: [];
|
|
|
|
return $table
|
|
->queryStringIdentifier('backupSetPolicyPicker'.Str::studly((string) $this->backupSetId))
|
|
->query(
|
|
Policy::query()
|
|
->where('tenant_id', $tenantId)
|
|
->when($existingPolicyIds !== [], fn (Builder $query) => $query->whereNotIn('id', $existingPolicyIds))
|
|
)
|
|
->deferLoading(! app()->runningUnitTests())
|
|
->paginated([25, 50, 100])
|
|
->defaultPaginationPageOption(25)
|
|
->searchable()
|
|
->striped()
|
|
->columns([
|
|
TextColumn::make('display_name')
|
|
->label('Name')
|
|
->searchable()
|
|
->sortable()
|
|
->wrap(),
|
|
TextColumn::make('policy_type')
|
|
->label('Type')
|
|
->badge()
|
|
->formatStateUsing(fn (?string $state): string => (string) (static::typeMeta($state)['label'] ?? $state ?? '—')),
|
|
TextColumn::make('platform')
|
|
->label('Platform')
|
|
->badge()
|
|
->default('—')
|
|
->sortable(),
|
|
TextColumn::make('external_id')
|
|
->label('External ID')
|
|
->formatStateUsing(fn (?string $state): string => static::externalIdShort($state))
|
|
->tooltip(fn (?string $state): ?string => filled($state) ? $state : null)
|
|
->extraAttributes(['class' => 'font-mono text-xs'])
|
|
->toggleable(),
|
|
TextColumn::make('versions_count')
|
|
->label('Versions')
|
|
->state(fn (Policy $record): int => (int) ($record->versions_count ?? 0))
|
|
->badge()
|
|
->sortable(),
|
|
TextColumn::make('last_synced_at')
|
|
->label('Last synced')
|
|
->dateTime()
|
|
->since()
|
|
->sortable()
|
|
->toggleable(),
|
|
TextColumn::make('ignored_at')
|
|
->label('Ignored')
|
|
->badge()
|
|
->color(fn (?string $state): string => filled($state) ? 'warning' : 'gray')
|
|
->formatStateUsing(fn (?string $state): string => filled($state) ? 'yes' : 'no')
|
|
->toggleable(isToggledHiddenByDefault: true),
|
|
])
|
|
->modifyQueryUsing(fn (Builder $query) => $query->withCount('versions'))
|
|
->filters([
|
|
SelectFilter::make('policy_type')
|
|
->label('Policy type')
|
|
->options(static::policyTypeOptions()),
|
|
SelectFilter::make('platform')
|
|
->label('Platform')
|
|
->options(fn (): array => Policy::query()
|
|
->where('tenant_id', $tenantId)
|
|
->whereNotNull('platform')
|
|
->distinct()
|
|
->orderBy('platform')
|
|
->pluck('platform', 'platform')
|
|
->all()),
|
|
SelectFilter::make('synced_within')
|
|
->label('Last synced')
|
|
->options([
|
|
'7' => 'Within 7 days',
|
|
'30' => 'Within 30 days',
|
|
'90' => 'Within 90 days',
|
|
'any' => 'Any time',
|
|
])
|
|
->default('7')
|
|
->query(function (Builder $query, array $data): Builder {
|
|
$value = (string) ($data['value'] ?? '7');
|
|
|
|
if ($value === 'any') {
|
|
return $query;
|
|
}
|
|
|
|
$days = is_numeric($value) ? (int) $value : 7;
|
|
|
|
return $query->where('last_synced_at', '>', now()->subDays(max(1, $days)));
|
|
}),
|
|
TernaryFilter::make('ignored')
|
|
->label('Ignored')
|
|
->nullable()
|
|
->queries(
|
|
true: fn (Builder $query) => $query->whereNotNull('ignored_at'),
|
|
false: fn (Builder $query) => $query->whereNull('ignored_at'),
|
|
)
|
|
->default(false),
|
|
SelectFilter::make('has_versions')
|
|
->label('Has versions')
|
|
->options([
|
|
'1' => 'Has versions',
|
|
'0' => 'No versions',
|
|
])
|
|
->query(function (Builder $query, array $data): Builder {
|
|
$value = $data['value'] ?? null;
|
|
|
|
if ($value === null || $value === '') {
|
|
return $query;
|
|
}
|
|
|
|
return match ((string) $value) {
|
|
'1' => $query->whereHas('versions'),
|
|
'0' => $query->whereDoesntHave('versions'),
|
|
default => $query,
|
|
};
|
|
}),
|
|
])
|
|
->bulkActions([
|
|
BulkAction::make('add_selected_to_backup_set')
|
|
->label('Add selected')
|
|
->icon('heroicon-m-plus')
|
|
->authorize(function (): bool {
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
$tenant = Tenant::current();
|
|
} catch (\RuntimeException) {
|
|
return false;
|
|
}
|
|
|
|
if (! $user->canSyncTenant($tenant)) {
|
|
return false;
|
|
}
|
|
|
|
return BackupSet::query()
|
|
->whereKey($this->backupSetId)
|
|
->where('tenant_id', $tenant->getKey())
|
|
->exists();
|
|
})
|
|
->action(function (Collection $records, BulkOperationService $bulkOperationService): void {
|
|
$backupSet = BackupSet::query()->findOrFail($this->backupSetId);
|
|
$tenant = null;
|
|
|
|
try {
|
|
$tenant = Tenant::current();
|
|
} catch (\RuntimeException) {
|
|
$tenant = $backupSet->tenant;
|
|
}
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
Notification::make()
|
|
->title('Not allowed')
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
Notification::make()
|
|
->title('Not allowed')
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
if ((int) $tenant->getKey() !== (int) $backupSet->tenant_id) {
|
|
Notification::make()
|
|
->title('Not allowed')
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
if (! $user->canSyncTenant($tenant)) {
|
|
Notification::make()
|
|
->title('Not allowed')
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$policyIds = $records
|
|
->pluck('id')
|
|
->map(fn (mixed $value): int => (int) $value)
|
|
->filter(fn (int $value): bool => $value > 0)
|
|
->unique()
|
|
->values()
|
|
->all();
|
|
|
|
if ($policyIds === []) {
|
|
Notification::make()
|
|
->title('No policies selected')
|
|
->warning()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
sort($policyIds);
|
|
|
|
$idempotencyKey = RunIdempotency::buildKey(
|
|
tenantId: (int) $tenant->getKey(),
|
|
operationType: 'backup_set.add_policies',
|
|
targetId: (string) $backupSet->getKey(),
|
|
context: [
|
|
'policy_ids' => $policyIds,
|
|
'include_assignments' => (bool) $this->include_assignments,
|
|
'include_scope_tags' => (bool) $this->include_scope_tags,
|
|
'include_foundations' => (bool) $this->include_foundations,
|
|
],
|
|
);
|
|
|
|
$existingRun = RunIdempotency::findActiveBulkOperationRun(
|
|
tenantId: (int) $tenant->getKey(),
|
|
idempotencyKey: $idempotencyKey,
|
|
);
|
|
|
|
if ($existingRun instanceof BulkOperationRun) {
|
|
Notification::make()
|
|
->title('Add policies already queued')
|
|
->body('A matching run is already queued or running. Open the run to monitor progress.')
|
|
->actions([
|
|
\Filament\Actions\Action::make('view_run')
|
|
->label('View run')
|
|
->url(BulkOperationRunResource::getUrl('view', ['record' => $existingRun], tenant: $tenant)),
|
|
])
|
|
->info()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$selectionPayload = [
|
|
'backup_set_id' => (int) $backupSet->getKey(),
|
|
'policy_ids' => $policyIds,
|
|
'options' => [
|
|
'include_assignments' => (bool) $this->include_assignments,
|
|
'include_scope_tags' => (bool) $this->include_scope_tags,
|
|
'include_foundations' => (bool) $this->include_foundations,
|
|
],
|
|
];
|
|
|
|
try {
|
|
$run = $bulkOperationService->createRun(
|
|
tenant: $tenant,
|
|
user: $user,
|
|
resource: 'backup_set',
|
|
action: 'add_policies',
|
|
itemIds: $selectionPayload,
|
|
totalItems: count($policyIds),
|
|
idempotencyKey: $idempotencyKey,
|
|
);
|
|
} catch (QueryException $exception) {
|
|
if ((string) $exception->getCode() === '23505') {
|
|
$existingRun = RunIdempotency::findActiveBulkOperationRun(
|
|
tenantId: (int) $tenant->getKey(),
|
|
idempotencyKey: $idempotencyKey,
|
|
);
|
|
|
|
if ($existingRun instanceof BulkOperationRun) {
|
|
Notification::make()
|
|
->title('Add policies already queued')
|
|
->body('A matching run is already queued or running. Open the run to monitor progress.')
|
|
->actions([
|
|
\Filament\Actions\Action::make('view_run')
|
|
->label('View run')
|
|
->url(BulkOperationRunResource::getUrl('view', ['record' => $existingRun], tenant: $tenant)),
|
|
])
|
|
->info()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
throw $exception;
|
|
}
|
|
|
|
AddPoliciesToBackupSetJob::dispatch(
|
|
bulkRunId: (int) $run->getKey(),
|
|
backupSetId: (int) $backupSet->getKey(),
|
|
includeAssignments: (bool) $this->include_assignments,
|
|
includeScopeTags: (bool) $this->include_scope_tags,
|
|
includeFoundations: (bool) $this->include_foundations,
|
|
);
|
|
|
|
$notificationTitle = $this->include_foundations
|
|
? 'Backup items queued'
|
|
: 'Policies queued';
|
|
|
|
Notification::make()
|
|
->title($notificationTitle)
|
|
->body('A background job has been queued. You can monitor progress in the run details or progress widget.')
|
|
->actions([
|
|
\Filament\Actions\Action::make('view_run')
|
|
->label('View run')
|
|
->url(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)),
|
|
])
|
|
->success()
|
|
->sendToDatabase($user)
|
|
->send();
|
|
|
|
$this->resetTable();
|
|
}),
|
|
]);
|
|
}
|
|
|
|
public function render(): View
|
|
{
|
|
return view('livewire.backup-set-policy-picker-table');
|
|
}
|
|
|
|
/**
|
|
* @return array{label:?string,category:?string,restore:?string,risk:?string}|array<string,mixed>
|
|
*/
|
|
private static function typeMeta(?string $type): array
|
|
{
|
|
if ($type === null) {
|
|
return [];
|
|
}
|
|
|
|
$types = array_merge(
|
|
config('tenantpilot.supported_policy_types', []),
|
|
config('tenantpilot.foundation_types', [])
|
|
);
|
|
|
|
return collect($types)
|
|
->firstWhere('type', $type) ?? [];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
private static function policyTypeOptions(): array
|
|
{
|
|
$types = array_merge(
|
|
config('tenantpilot.supported_policy_types', []),
|
|
config('tenantpilot.foundation_types', [])
|
|
);
|
|
|
|
return collect($types)
|
|
->mapWithKeys(function (array $meta): array {
|
|
$type = (string) ($meta['type'] ?? '');
|
|
|
|
if ($type === '') {
|
|
return [];
|
|
}
|
|
|
|
$label = (string) ($meta['label'] ?? $type);
|
|
|
|
return [$type => $label];
|
|
})
|
|
->all();
|
|
}
|
|
}
|