TenantAtlas/app/Livewire/BackupSetPolicyPickerTable.php
2026-01-15 21:45:08 +01:00

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();
}
}