feat/032-backup-scheduling-mvp (#36)
Adds Backup Scheduling MVP (CRUD, dispatcher, run job, retention, audit logs) Run now / Retry persist Filament DB notifications Bulk Run/Retry now create BulkOperationRun so bottom-right progress widget shows them Progress widget includes “recent finished” window + reconciles stale backup bulk runs Adds purge command + migration backup_schedule_runs.user_id + tests updates Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local> Reviewed-on: #36
This commit is contained in:
parent
4d3fcd28a9
commit
a62c855851
@ -35,6 +35,13 @@ ## Bulk operations (Feature 005)
|
||||
- Destructive operations require type-to-confirm at higher thresholds (e.g. `DELETE`).
|
||||
- Long-running bulk ops are queued; the bottom-right progress widget polls for active runs.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
- **Progress stuck on “Queued…”** usually means the queue worker is not running (or not processing the queue you expect).
|
||||
- Prefer using the Sail/Docker worker (see `docker-compose.yml`) rather than starting an additional local `php artisan queue:work`.
|
||||
- Check worker status/logs: `./vendor/bin/sail ps` and `./vendor/bin/sail logs -f queue`.
|
||||
- **Exit code 137** for `queue:work` typically means the process was killed (often OOM). Increase Docker memory/limits or run the worker inside the container.
|
||||
|
||||
### Configuration
|
||||
|
||||
- `TENANTPILOT_BULK_CHUNK_SIZE` (default `10`): job refresh/progress chunk size.
|
||||
|
||||
164
app/Console/Commands/TenantpilotPurgeNonPersistentData.php
Normal file
164
app/Console/Commands/TenantpilotPurgeNonPersistentData.php
Normal file
@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupScheduleRun;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
|
||||
class TenantpilotPurgeNonPersistentData extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'tenantpilot:purge-nonpersistent
|
||||
{tenant? : Tenant id / tenant_id / external_id (defaults to current tenant)}
|
||||
{--all : Purge for all tenants}
|
||||
{--force : Actually delete rows}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Permanently delete non-persistent (regeneratable) tenant data like policies, backups, runs, and logs.';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$tenants = $this->resolveTenants();
|
||||
|
||||
if ($tenants->isEmpty()) {
|
||||
$this->error('No tenants selected. Provide {tenant} or use --all.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$isDryRun = ! (bool) $this->option('force');
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->warn('Dry run: no rows will be deleted. Re-run with --force to apply.');
|
||||
} else {
|
||||
$this->warn('This will PERMANENTLY delete non-persistent tenant data.');
|
||||
|
||||
if ($this->input->isInteractive() && ! $this->confirm('Proceed?', false)) {
|
||||
$this->info('Aborted.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($tenants as $tenant) {
|
||||
$counts = $this->countsForTenant($tenant);
|
||||
|
||||
$this->line('');
|
||||
$this->info("Tenant: {$tenant->id} ({$tenant->name})");
|
||||
$this->table(
|
||||
['Table', 'Rows'],
|
||||
collect($counts)
|
||||
->map(fn (int $count, string $table) => [$table, $count])
|
||||
->values()
|
||||
->all(),
|
||||
);
|
||||
|
||||
if ($isDryRun) {
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($tenant): void {
|
||||
BackupScheduleRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->delete();
|
||||
|
||||
BackupSchedule::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->delete();
|
||||
|
||||
BulkOperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->delete();
|
||||
|
||||
AuditLog::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->delete();
|
||||
|
||||
RestoreRun::withTrashed()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->forceDelete();
|
||||
|
||||
BackupItem::withTrashed()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->forceDelete();
|
||||
|
||||
BackupSet::withTrashed()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->forceDelete();
|
||||
|
||||
PolicyVersion::withTrashed()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->forceDelete();
|
||||
|
||||
Policy::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->delete();
|
||||
});
|
||||
|
||||
$this->info('Purged.');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function resolveTenants()
|
||||
{
|
||||
if ((bool) $this->option('all')) {
|
||||
return Tenant::query()->get();
|
||||
}
|
||||
|
||||
$tenantArg = $this->argument('tenant');
|
||||
|
||||
if ($tenantArg !== null && $tenantArg !== '') {
|
||||
$tenant = Tenant::query()->forTenant($tenantArg)->first();
|
||||
|
||||
return $tenant ? collect([$tenant]) : collect();
|
||||
}
|
||||
|
||||
try {
|
||||
return collect([Tenant::current()]);
|
||||
} catch (RuntimeException) {
|
||||
return collect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,int>
|
||||
*/
|
||||
private function countsForTenant(Tenant $tenant): array
|
||||
{
|
||||
return [
|
||||
'backup_schedule_runs' => BackupScheduleRun::query()->where('tenant_id', $tenant->id)->count(),
|
||||
'backup_schedules' => BackupSchedule::query()->where('tenant_id', $tenant->id)->count(),
|
||||
'bulk_operation_runs' => BulkOperationRun::query()->where('tenant_id', $tenant->id)->count(),
|
||||
'audit_logs' => AuditLog::query()->where('tenant_id', $tenant->id)->count(),
|
||||
'restore_runs' => RestoreRun::withTrashed()->where('tenant_id', $tenant->id)->count(),
|
||||
'backup_items' => BackupItem::withTrashed()->where('tenant_id', $tenant->id)->count(),
|
||||
'backup_sets' => BackupSet::withTrashed()->where('tenant_id', $tenant->id)->count(),
|
||||
'policy_versions' => PolicyVersion::withTrashed()->where('tenant_id', $tenant->id)->count(),
|
||||
'policies' => Policy::query()->where('tenant_id', $tenant->id)->count(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -10,10 +10,10 @@
|
||||
use App\Models\BackupScheduleRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Notifications\BackupScheduleRunDispatchedNotification;
|
||||
use App\Rules\SupportedPolicyTypesRule;
|
||||
use App\Services\BackupScheduling\PolicyTypeResolver;
|
||||
use App\Services\BackupScheduling\ScheduleTimeService;
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Support\TenantRole;
|
||||
use BackedEnum;
|
||||
@ -30,6 +30,7 @@
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Notifications\Events\DatabaseNotificationsSent;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
@ -41,6 +42,7 @@
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@ -200,50 +202,8 @@ public static function table(Table $table): Table
|
||||
|
||||
TextColumn::make('policy_types')
|
||||
->label('Policy types')
|
||||
->wrap()
|
||||
->getStateUsing(function (BackupSchedule $record): string {
|
||||
$state = $record->policy_types;
|
||||
|
||||
if (is_string($state)) {
|
||||
$decoded = json_decode($state, true);
|
||||
|
||||
if (is_array($decoded)) {
|
||||
$state = $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
if ($state instanceof \Illuminate\Contracts\Support\Arrayable) {
|
||||
$state = $state->toArray();
|
||||
}
|
||||
|
||||
if (! is_array($state)) {
|
||||
return 'None';
|
||||
}
|
||||
|
||||
$types = array_is_list($state)
|
||||
? $state
|
||||
: array_keys(array_filter($state));
|
||||
|
||||
$types = array_values(array_filter($types, fn (mixed $type): bool => is_string($type) && $type !== ''));
|
||||
|
||||
if ($types === []) {
|
||||
return 'None';
|
||||
}
|
||||
|
||||
$labelMap = collect(config('tenantpilot.supported_policy_types', []))
|
||||
->mapWithKeys(fn (array $policy): array => [
|
||||
(string) ($policy['type'] ?? '') => (string) ($policy['label'] ?? Str::headline((string) ($policy['type'] ?? ''))),
|
||||
])
|
||||
->filter(fn (string $label, string $type): bool => $type !== '')
|
||||
->all();
|
||||
|
||||
$labels = array_map(
|
||||
fn (string $type): string => $labelMap[$type] ?? Str::headline($type),
|
||||
$types,
|
||||
);
|
||||
|
||||
return implode(', ', $labels);
|
||||
}),
|
||||
->getStateUsing(fn (BackupSchedule $record): string => static::policyTypesPreviewLabel($record))
|
||||
->tooltip(fn (BackupSchedule $record): string => static::policyTypesFullLabel($record)),
|
||||
|
||||
TextColumn::make('retention_keep_last')
|
||||
->label('Retention')
|
||||
@ -278,7 +238,21 @@ public static function table(Table $table): Table
|
||||
|
||||
TextColumn::make('next_run_at')
|
||||
->label('Next run')
|
||||
->dateTime()
|
||||
->getStateUsing(function (BackupSchedule $record): ?string {
|
||||
$nextRun = $record->next_run_at;
|
||||
|
||||
if (! $nextRun) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$timezone = $record->timezone ?: 'UTC';
|
||||
|
||||
try {
|
||||
return $nextRun->setTimezone($timezone)->format('M j, Y H:i:s');
|
||||
} catch (\Throwable) {
|
||||
return $nextRun->format('M j, Y H:i:s');
|
||||
}
|
||||
})
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
@ -318,28 +292,37 @@ public static function table(Table $table): Table
|
||||
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
$userId = auth()->id();
|
||||
$userModel = $user instanceof User ? $user : ($userId ? User::query()->find($userId) : null);
|
||||
|
||||
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
|
||||
$run = null;
|
||||
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$exists = BackupScheduleRun::query()
|
||||
->where('backup_schedule_id', $record->id)
|
||||
->where('scheduled_for', $scheduledFor)
|
||||
->exists();
|
||||
|
||||
if (! $exists) {
|
||||
try {
|
||||
$run = BackupScheduleRun::create([
|
||||
'backup_schedule_id' => $record->id,
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'user_id' => $userId,
|
||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||
'status' => BackupScheduleRun::STATUS_RUNNING,
|
||||
'summary' => null,
|
||||
]);
|
||||
break;
|
||||
} catch (UniqueConstraintViolationException) {
|
||||
$scheduledFor = $scheduledFor->addMinute();
|
||||
}
|
||||
|
||||
$scheduledFor = $scheduledFor->addMinute();
|
||||
}
|
||||
|
||||
$run = BackupScheduleRun::create([
|
||||
'backup_schedule_id' => $record->id,
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||
'status' => BackupScheduleRun::STATUS_RUNNING,
|
||||
'summary' => null,
|
||||
]);
|
||||
if (! $run instanceof BackupScheduleRun) {
|
||||
Notification::make()
|
||||
->title('Run already queued')
|
||||
->body('Please wait a moment and try again.')
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
app(AuditLogger::class)->log(
|
||||
tenant: $tenant,
|
||||
@ -357,23 +340,34 @@ public static function table(Table $table): Table
|
||||
],
|
||||
);
|
||||
|
||||
Bus::dispatch(new RunBackupScheduleJob($run->id));
|
||||
$bulkRunId = null;
|
||||
|
||||
if ($user instanceof User) {
|
||||
$user->notify(new BackupScheduleRunDispatchedNotification([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'backup_schedule_id' => (int) $record->id,
|
||||
'backup_schedule_run_id' => (int) $run->id,
|
||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||
'trigger' => 'run_now',
|
||||
]));
|
||||
if ($userModel instanceof User) {
|
||||
$bulkRunId = app(BulkOperationService::class)
|
||||
->createRun(
|
||||
tenant: $tenant,
|
||||
user: $userModel,
|
||||
resource: 'backup_schedule',
|
||||
action: 'run',
|
||||
itemIds: [(string) $record->id],
|
||||
totalItems: 1,
|
||||
)
|
||||
->id;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRunId));
|
||||
|
||||
$notification = Notification::make()
|
||||
->title('Run dispatched')
|
||||
->body('The backup run has been queued.')
|
||||
->success()
|
||||
->send();
|
||||
->success();
|
||||
|
||||
if ($userModel instanceof User) {
|
||||
$userModel->notifyNow($notification->toDatabase());
|
||||
DatabaseNotificationsSent::dispatch($userModel);
|
||||
}
|
||||
|
||||
$notification->send();
|
||||
}),
|
||||
Action::make('retry')
|
||||
->label('Retry')
|
||||
@ -385,28 +379,37 @@ public static function table(Table $table): Table
|
||||
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
$userId = auth()->id();
|
||||
$userModel = $user instanceof User ? $user : ($userId ? User::query()->find($userId) : null);
|
||||
|
||||
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
|
||||
$run = null;
|
||||
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$exists = BackupScheduleRun::query()
|
||||
->where('backup_schedule_id', $record->id)
|
||||
->where('scheduled_for', $scheduledFor)
|
||||
->exists();
|
||||
|
||||
if (! $exists) {
|
||||
try {
|
||||
$run = BackupScheduleRun::create([
|
||||
'backup_schedule_id' => $record->id,
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'user_id' => $userId,
|
||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||
'status' => BackupScheduleRun::STATUS_RUNNING,
|
||||
'summary' => null,
|
||||
]);
|
||||
break;
|
||||
} catch (UniqueConstraintViolationException) {
|
||||
$scheduledFor = $scheduledFor->addMinute();
|
||||
}
|
||||
|
||||
$scheduledFor = $scheduledFor->addMinute();
|
||||
}
|
||||
|
||||
$run = BackupScheduleRun::create([
|
||||
'backup_schedule_id' => $record->id,
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||
'status' => BackupScheduleRun::STATUS_RUNNING,
|
||||
'summary' => null,
|
||||
]);
|
||||
if (! $run instanceof BackupScheduleRun) {
|
||||
Notification::make()
|
||||
->title('Retry already queued')
|
||||
->body('Please wait a moment and try again.')
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
app(AuditLogger::class)->log(
|
||||
tenant: $tenant,
|
||||
@ -424,23 +427,34 @@ public static function table(Table $table): Table
|
||||
],
|
||||
);
|
||||
|
||||
Bus::dispatch(new RunBackupScheduleJob($run->id));
|
||||
$bulkRunId = null;
|
||||
|
||||
if ($user instanceof User) {
|
||||
$user->notify(new BackupScheduleRunDispatchedNotification([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'backup_schedule_id' => (int) $record->id,
|
||||
'backup_schedule_run_id' => (int) $run->id,
|
||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||
'trigger' => 'retry',
|
||||
]));
|
||||
if ($userModel instanceof User) {
|
||||
$bulkRunId = app(BulkOperationService::class)
|
||||
->createRun(
|
||||
tenant: $tenant,
|
||||
user: $userModel,
|
||||
resource: 'backup_schedule',
|
||||
action: 'retry',
|
||||
itemIds: [(string) $record->id],
|
||||
totalItems: 1,
|
||||
)
|
||||
->id;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRunId));
|
||||
|
||||
$notification = Notification::make()
|
||||
->title('Retry dispatched')
|
||||
->body('A new backup run has been queued.')
|
||||
->success()
|
||||
->send();
|
||||
->success();
|
||||
|
||||
if ($userModel instanceof User) {
|
||||
$userModel->notifyNow($notification->toDatabase());
|
||||
DatabaseNotificationsSent::dispatch($userModel);
|
||||
}
|
||||
|
||||
$notification->send();
|
||||
}),
|
||||
EditAction::make()
|
||||
->visible(fn (): bool => static::currentTenantRole()?->canManageBackupSchedules() ?? false),
|
||||
@ -463,33 +477,47 @@ public static function table(Table $table): Table
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
$userId = auth()->id();
|
||||
$user = $userId ? User::query()->find($userId) : null;
|
||||
|
||||
$bulkRun = null;
|
||||
if ($user) {
|
||||
$bulkRun = app(\App\Services\BulkOperationService::class)->createRun(
|
||||
tenant: $tenant,
|
||||
user: $user,
|
||||
resource: 'backup_schedule',
|
||||
action: 'run',
|
||||
itemIds: $records->pluck('id')->map(fn (mixed $id): int => (int) $id)->values()->all(),
|
||||
totalItems: $records->count(),
|
||||
);
|
||||
}
|
||||
|
||||
$createdRunIds = [];
|
||||
|
||||
/** @var BackupSchedule $record */
|
||||
foreach ($records as $record) {
|
||||
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
|
||||
$run = null;
|
||||
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$exists = BackupScheduleRun::query()
|
||||
->where('backup_schedule_id', $record->id)
|
||||
->where('scheduled_for', $scheduledFor)
|
||||
->exists();
|
||||
|
||||
if (! $exists) {
|
||||
try {
|
||||
$run = BackupScheduleRun::create([
|
||||
'backup_schedule_id' => $record->id,
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'user_id' => $userId,
|
||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||
'status' => BackupScheduleRun::STATUS_RUNNING,
|
||||
'summary' => null,
|
||||
]);
|
||||
break;
|
||||
} catch (UniqueConstraintViolationException) {
|
||||
$scheduledFor = $scheduledFor->addMinute();
|
||||
}
|
||||
|
||||
$scheduledFor = $scheduledFor->addMinute();
|
||||
}
|
||||
|
||||
$run = BackupScheduleRun::create([
|
||||
'backup_schedule_id' => $record->id,
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||
'status' => BackupScheduleRun::STATUS_RUNNING,
|
||||
'summary' => null,
|
||||
]);
|
||||
if (! $run instanceof BackupScheduleRun) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$createdRunIds[] = (int) $run->id;
|
||||
|
||||
@ -505,28 +533,30 @@ public static function table(Table $table): Table
|
||||
'backup_schedule_run_id' => $run->id,
|
||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||
'trigger' => 'bulk_run_now',
|
||||
'bulk_run_id' => $bulkRun?->id,
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
Bus::dispatch(new RunBackupScheduleJob($run->id));
|
||||
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRun?->id));
|
||||
}
|
||||
|
||||
$notification = Notification::make()
|
||||
->title('Runs dispatched')
|
||||
->body(sprintf('Queued %d run(s).', count($createdRunIds)));
|
||||
|
||||
if (count($createdRunIds) === 0) {
|
||||
$notification->warning();
|
||||
} else {
|
||||
$notification->success();
|
||||
}
|
||||
|
||||
if ($user instanceof User) {
|
||||
$user->notify(new BackupScheduleRunDispatchedNotification([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'schedule_ids' => $records->pluck('id')->map(fn ($id) => (int) $id)->values()->all(),
|
||||
'backup_schedule_run_ids' => $createdRunIds,
|
||||
'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute()->toDateTimeString(),
|
||||
'trigger' => 'bulk_run_now',
|
||||
]));
|
||||
$user->notifyNow($notification->toDatabase());
|
||||
DatabaseNotificationsSent::dispatch($user);
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Runs dispatched')
|
||||
->body(sprintf('Queued %d run(s).', count($createdRunIds)))
|
||||
->success()
|
||||
->send();
|
||||
$notification->send();
|
||||
}),
|
||||
BulkAction::make('bulk_retry')
|
||||
->label('Retry')
|
||||
@ -541,33 +571,47 @@ public static function table(Table $table): Table
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
$userId = auth()->id();
|
||||
$user = $userId ? User::query()->find($userId) : null;
|
||||
|
||||
$bulkRun = null;
|
||||
if ($user) {
|
||||
$bulkRun = app(\App\Services\BulkOperationService::class)->createRun(
|
||||
tenant: $tenant,
|
||||
user: $user,
|
||||
resource: 'backup_schedule',
|
||||
action: 'retry',
|
||||
itemIds: $records->pluck('id')->map(fn (mixed $id): int => (int) $id)->values()->all(),
|
||||
totalItems: $records->count(),
|
||||
);
|
||||
}
|
||||
|
||||
$createdRunIds = [];
|
||||
|
||||
/** @var BackupSchedule $record */
|
||||
foreach ($records as $record) {
|
||||
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
|
||||
$run = null;
|
||||
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$exists = BackupScheduleRun::query()
|
||||
->where('backup_schedule_id', $record->id)
|
||||
->where('scheduled_for', $scheduledFor)
|
||||
->exists();
|
||||
|
||||
if (! $exists) {
|
||||
try {
|
||||
$run = BackupScheduleRun::create([
|
||||
'backup_schedule_id' => $record->id,
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'user_id' => $userId,
|
||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||
'status' => BackupScheduleRun::STATUS_RUNNING,
|
||||
'summary' => null,
|
||||
]);
|
||||
break;
|
||||
} catch (UniqueConstraintViolationException) {
|
||||
$scheduledFor = $scheduledFor->addMinute();
|
||||
}
|
||||
|
||||
$scheduledFor = $scheduledFor->addMinute();
|
||||
}
|
||||
|
||||
$run = BackupScheduleRun::create([
|
||||
'backup_schedule_id' => $record->id,
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||
'status' => BackupScheduleRun::STATUS_RUNNING,
|
||||
'summary' => null,
|
||||
]);
|
||||
if (! $run instanceof BackupScheduleRun) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$createdRunIds[] = (int) $run->id;
|
||||
|
||||
@ -583,28 +627,30 @@ public static function table(Table $table): Table
|
||||
'backup_schedule_run_id' => $run->id,
|
||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||
'trigger' => 'bulk_retry',
|
||||
'bulk_run_id' => $bulkRun?->id,
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
Bus::dispatch(new RunBackupScheduleJob($run->id));
|
||||
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRun?->id));
|
||||
}
|
||||
|
||||
$notification = Notification::make()
|
||||
->title('Retries dispatched')
|
||||
->body(sprintf('Queued %d run(s).', count($createdRunIds)));
|
||||
|
||||
if (count($createdRunIds) === 0) {
|
||||
$notification->warning();
|
||||
} else {
|
||||
$notification->success();
|
||||
}
|
||||
|
||||
if ($user instanceof User) {
|
||||
$user->notify(new BackupScheduleRunDispatchedNotification([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'schedule_ids' => $records->pluck('id')->map(fn ($id) => (int) $id)->values()->all(),
|
||||
'backup_schedule_run_ids' => $createdRunIds,
|
||||
'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute()->toDateTimeString(),
|
||||
'trigger' => 'bulk_retry',
|
||||
]));
|
||||
$user->notifyNow($notification->toDatabase());
|
||||
DatabaseNotificationsSent::dispatch($user);
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Retries dispatched')
|
||||
->body(sprintf('Queued %d run(s).', count($createdRunIds)))
|
||||
->success()
|
||||
->send();
|
||||
$notification->send();
|
||||
}),
|
||||
DeleteBulkAction::make('bulk_delete')
|
||||
->visible(fn (): bool => static::currentTenantRole()?->canManageBackupSchedules() ?? false),
|
||||
@ -638,6 +684,79 @@ public static function getPages(): array
|
||||
];
|
||||
}
|
||||
|
||||
public static function policyTypesFullLabel(BackupSchedule $record): string
|
||||
{
|
||||
$labels = static::policyTypesLabels($record);
|
||||
|
||||
return $labels === [] ? 'None' : implode(', ', $labels);
|
||||
}
|
||||
|
||||
public static function policyTypesPreviewLabel(BackupSchedule $record): string
|
||||
{
|
||||
$labels = static::policyTypesLabels($record);
|
||||
|
||||
if ($labels === []) {
|
||||
return 'None';
|
||||
}
|
||||
|
||||
$preview = array_slice($labels, 0, 2);
|
||||
$remaining = count($labels) - count($preview);
|
||||
|
||||
$label = implode(', ', $preview);
|
||||
|
||||
if ($remaining > 0) {
|
||||
$label .= sprintf(' +%d more', $remaining);
|
||||
}
|
||||
|
||||
return $label;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private static function policyTypesLabels(BackupSchedule $record): array
|
||||
{
|
||||
$state = $record->policy_types;
|
||||
|
||||
if (is_string($state)) {
|
||||
$decoded = json_decode($state, true);
|
||||
|
||||
if (is_array($decoded)) {
|
||||
$state = $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
if ($state instanceof \Illuminate\Contracts\Support\Arrayable) {
|
||||
$state = $state->toArray();
|
||||
}
|
||||
|
||||
if (blank($state) || (! is_array($state))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$types = array_is_list($state)
|
||||
? $state
|
||||
: array_keys(array_filter($state));
|
||||
|
||||
$types = array_values(array_filter($types, fn (mixed $type): bool => is_string($type) && $type !== ''));
|
||||
|
||||
if ($types === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$labelMap = collect(config('tenantpilot.supported_policy_types', []))
|
||||
->mapWithKeys(fn (array $policy): array => [
|
||||
(string) ($policy['type'] ?? '') => (string) ($policy['label'] ?? Str::headline((string) ($policy['type'] ?? ''))),
|
||||
])
|
||||
->filter(fn (string $label, string $type): bool => $type !== '')
|
||||
->all();
|
||||
|
||||
return array_map(
|
||||
fn (string $type): string => $labelMap[$type] ?? Str::headline($type),
|
||||
$types,
|
||||
);
|
||||
}
|
||||
|
||||
public static function ensurePolicyTypes(array $data): array
|
||||
{
|
||||
$types = array_values((array) ($data['policy_types'] ?? []));
|
||||
|
||||
@ -4,13 +4,17 @@
|
||||
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupScheduleRun;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Services\BackupScheduling\PolicyTypeResolver;
|
||||
use App\Services\BackupScheduling\RunErrorMapper;
|
||||
use App\Services\BackupScheduling\ScheduleTimeService;
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Intune\BackupService;
|
||||
use App\Services\Intune\PolicySyncService;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Filament\Notifications\Events\DatabaseNotificationsSent;
|
||||
use Filament\Notifications\Notification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
@ -26,7 +30,10 @@ class RunBackupScheduleJob implements ShouldQueue
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public function __construct(public int $backupScheduleRunId) {}
|
||||
public function __construct(
|
||||
public int $backupScheduleRunId,
|
||||
public ?int $bulkRunId = null,
|
||||
) {}
|
||||
|
||||
public function handle(
|
||||
PolicySyncService $policySyncService,
|
||||
@ -35,15 +42,31 @@ public function handle(
|
||||
ScheduleTimeService $scheduleTimeService,
|
||||
AuditLogger $auditLogger,
|
||||
RunErrorMapper $errorMapper,
|
||||
BulkOperationService $bulkOperationService,
|
||||
): void {
|
||||
$run = BackupScheduleRun::query()
|
||||
->with(['schedule', 'tenant'])
|
||||
->with(['schedule', 'tenant', 'user'])
|
||||
->find($this->backupScheduleRunId);
|
||||
|
||||
if (! $run) {
|
||||
return;
|
||||
}
|
||||
|
||||
$bulkRun = $this->bulkRunId
|
||||
? BulkOperationRun::query()->with(['tenant', 'user'])->find($this->bulkRunId)
|
||||
: null;
|
||||
|
||||
if (
|
||||
$bulkRun
|
||||
&& ($bulkRun->tenant_id !== $run->tenant_id || $bulkRun->user_id !== $run->user_id)
|
||||
) {
|
||||
$bulkRun = null;
|
||||
}
|
||||
|
||||
if ($bulkRun && $bulkRun->status === 'pending') {
|
||||
$bulkOperationService->start($bulkRun);
|
||||
}
|
||||
|
||||
$schedule = $run->schedule;
|
||||
|
||||
if (! $schedule instanceof BackupSchedule) {
|
||||
@ -81,6 +104,7 @@ public function handle(
|
||||
errorMessage: 'Another run is already in progress for this schedule.',
|
||||
summary: ['reason' => 'concurrent_run'],
|
||||
scheduleTimeService: $scheduleTimeService,
|
||||
bulkRunId: $this->bulkRunId,
|
||||
);
|
||||
|
||||
return;
|
||||
@ -94,6 +118,8 @@ public function handle(
|
||||
'status' => BackupScheduleRun::STATUS_RUNNING,
|
||||
])->save();
|
||||
|
||||
$this->notifyRunStarted($run, $schedule);
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'backup_schedule.run_started',
|
||||
@ -124,6 +150,7 @@ public function handle(
|
||||
'unknown_policy_types' => $unknownTypes,
|
||||
],
|
||||
scheduleTimeService: $scheduleTimeService,
|
||||
bulkRunId: $this->bulkRunId,
|
||||
);
|
||||
|
||||
return;
|
||||
@ -182,6 +209,7 @@ public function handle(
|
||||
summary: $summary,
|
||||
scheduleTimeService: $scheduleTimeService,
|
||||
backupSetId: (string) $backupSet->id,
|
||||
bulkRunId: $this->bulkRunId,
|
||||
);
|
||||
|
||||
$auditLogger->log(
|
||||
@ -220,6 +248,7 @@ public function handle(
|
||||
'attempt' => $attempt,
|
||||
],
|
||||
scheduleTimeService: $scheduleTimeService,
|
||||
bulkRunId: $this->bulkRunId,
|
||||
);
|
||||
|
||||
$auditLogger->log(
|
||||
@ -241,6 +270,56 @@ public function handle(
|
||||
}
|
||||
}
|
||||
|
||||
private function notifyRunStarted(BackupScheduleRun $run, BackupSchedule $schedule): void
|
||||
{
|
||||
$user = $run->user;
|
||||
|
||||
if (! $user) {
|
||||
return;
|
||||
}
|
||||
|
||||
$notification = Notification::make()
|
||||
->title('Backup started')
|
||||
->body(sprintf('Schedule "%s" has started.', $schedule->name))
|
||||
->info();
|
||||
|
||||
$user->notifyNow($notification->toDatabase());
|
||||
DatabaseNotificationsSent::dispatch($user);
|
||||
}
|
||||
|
||||
private function notifyRunFinished(BackupScheduleRun $run, BackupSchedule $schedule): void
|
||||
{
|
||||
$user = $run->user;
|
||||
|
||||
if (! $user) {
|
||||
return;
|
||||
}
|
||||
|
||||
$title = match ($run->status) {
|
||||
BackupScheduleRun::STATUS_SUCCESS => 'Backup completed',
|
||||
BackupScheduleRun::STATUS_PARTIAL => 'Backup completed (partial)',
|
||||
BackupScheduleRun::STATUS_SKIPPED => 'Backup skipped',
|
||||
default => 'Backup failed',
|
||||
};
|
||||
|
||||
$notification = Notification::make()
|
||||
->title($title)
|
||||
->body(sprintf('Schedule "%s" finished with status: %s.', $schedule->name, $run->status));
|
||||
|
||||
if (filled($run->error_message)) {
|
||||
$notification->body($notification->getBody()."\n".$run->error_message);
|
||||
}
|
||||
|
||||
match ($run->status) {
|
||||
BackupScheduleRun::STATUS_SUCCESS => $notification->success(),
|
||||
BackupScheduleRun::STATUS_PARTIAL, BackupScheduleRun::STATUS_SKIPPED => $notification->warning(),
|
||||
default => $notification->danger(),
|
||||
};
|
||||
|
||||
$user->notifyNow($notification->toDatabase());
|
||||
DatabaseNotificationsSent::dispatch($user);
|
||||
}
|
||||
|
||||
private function finishRun(
|
||||
BackupScheduleRun $run,
|
||||
BackupSchedule $schedule,
|
||||
@ -250,6 +329,7 @@ private function finishRun(
|
||||
array $summary,
|
||||
ScheduleTimeService $scheduleTimeService,
|
||||
?string $backupSetId = null,
|
||||
?int $bulkRunId = null,
|
||||
): void {
|
||||
$nowUtc = CarbonImmutable::now('UTC');
|
||||
|
||||
@ -268,6 +348,50 @@ private function finishRun(
|
||||
'next_run_at' => $scheduleTimeService->nextRunFor($schedule, $nowUtc),
|
||||
])->saveQuietly();
|
||||
|
||||
$this->notifyRunFinished($run, $schedule);
|
||||
|
||||
if ($bulkRunId) {
|
||||
$bulkRun = BulkOperationRun::query()->with(['tenant', 'user'])->find($bulkRunId);
|
||||
|
||||
if (
|
||||
$bulkRun
|
||||
&& ($bulkRun->tenant_id === $run->tenant_id)
|
||||
&& ($bulkRun->user_id === $run->user_id)
|
||||
&& in_array($bulkRun->status, ['pending', 'running'], true)
|
||||
) {
|
||||
$service = app(BulkOperationService::class);
|
||||
|
||||
$itemId = (string) $run->backup_schedule_id;
|
||||
|
||||
match ($status) {
|
||||
BackupScheduleRun::STATUS_SUCCESS => $service->recordSuccess($bulkRun),
|
||||
BackupScheduleRun::STATUS_SKIPPED => $service->recordSkippedWithReason(
|
||||
$bulkRun,
|
||||
$itemId,
|
||||
$errorMessage ?: 'Skipped',
|
||||
),
|
||||
BackupScheduleRun::STATUS_PARTIAL => $service->recordFailure(
|
||||
$bulkRun,
|
||||
$itemId,
|
||||
$errorMessage ?: 'Completed partially',
|
||||
),
|
||||
default => $service->recordFailure(
|
||||
$bulkRun,
|
||||
$itemId,
|
||||
$errorMessage ?: ($errorCode ?: 'Failed'),
|
||||
),
|
||||
};
|
||||
|
||||
$bulkRun->refresh();
|
||||
if (
|
||||
in_array($bulkRun->status, ['pending', 'running'], true)
|
||||
&& $bulkRun->processed_items >= $bulkRun->total_items
|
||||
) {
|
||||
$service->complete($bulkRun);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($backupSetId && in_array($status, [BackupScheduleRun::STATUS_SUCCESS, BackupScheduleRun::STATUS_PARTIAL], true)) {
|
||||
Bus::dispatch(new ApplyBackupScheduleRetentionJob($schedule->id));
|
||||
}
|
||||
|
||||
@ -2,8 +2,10 @@
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\BackupScheduleRun;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Support\Arr;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
|
||||
@ -13,9 +15,12 @@ class BulkOperationProgress extends Component
|
||||
|
||||
public int $pollSeconds = 3;
|
||||
|
||||
public int $recentFinishedSeconds = 12;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->pollSeconds = max(1, min(10, (int) config('tenantpilot.bulk_operations.poll_interval_seconds', 3)));
|
||||
$this->recentFinishedSeconds = max(3, min(60, (int) config('tenantpilot.bulk_operations.recent_finished_seconds', 12)));
|
||||
$this->loadRuns();
|
||||
}
|
||||
|
||||
@ -35,12 +40,102 @@ public function loadRuns()
|
||||
return;
|
||||
}
|
||||
|
||||
$recentThreshold = now()->subSeconds($this->recentFinishedSeconds);
|
||||
|
||||
$this->runs = BulkOperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('user_id', auth()->id())
|
||||
->whereIn('status', ['pending', 'running'])
|
||||
->where(function ($query) use ($recentThreshold): void {
|
||||
$query->whereIn('status', ['pending', 'running'])
|
||||
->orWhere(function ($query) use ($recentThreshold): void {
|
||||
$query->whereIn('status', ['completed', 'completed_with_errors', 'failed', 'aborted'])
|
||||
->where('updated_at', '>=', $recentThreshold);
|
||||
});
|
||||
})
|
||||
->orderByDesc('created_at')
|
||||
->get();
|
||||
|
||||
$this->reconcileBackupScheduleRuns($tenant->id);
|
||||
}
|
||||
|
||||
private function reconcileBackupScheduleRuns(int $tenantId): void
|
||||
{
|
||||
$userId = auth()->id();
|
||||
|
||||
if (! $userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$staleThreshold = now()->subSeconds(60);
|
||||
|
||||
foreach ($this->runs as $bulkRun) {
|
||||
if ($bulkRun->resource !== 'backup_schedule') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! in_array($bulkRun->status, ['pending', 'running'], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $bulkRun->created_at || $bulkRun->created_at->gt($staleThreshold)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$scheduleId = (int) Arr::first($bulkRun->item_ids ?? []);
|
||||
|
||||
if ($scheduleId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$scheduleRun = BackupScheduleRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('user_id', $userId)
|
||||
->where('backup_schedule_id', $scheduleId)
|
||||
->where('created_at', '>=', $bulkRun->created_at)
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if (! $scheduleRun) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($scheduleRun->finished_at) {
|
||||
$processed = 1;
|
||||
$succeeded = 0;
|
||||
$failed = 0;
|
||||
$skipped = 0;
|
||||
$status = 'completed';
|
||||
|
||||
switch ($scheduleRun->status) {
|
||||
case BackupScheduleRun::STATUS_SUCCESS:
|
||||
$succeeded = 1;
|
||||
break;
|
||||
|
||||
case BackupScheduleRun::STATUS_SKIPPED:
|
||||
$skipped = 1;
|
||||
break;
|
||||
|
||||
default:
|
||||
$failed = 1;
|
||||
$status = 'completed_with_errors';
|
||||
break;
|
||||
}
|
||||
|
||||
$bulkRun->forceFill([
|
||||
'status' => $status,
|
||||
'processed_items' => $processed,
|
||||
'succeeded' => $succeeded,
|
||||
'failed' => $failed,
|
||||
'skipped' => $skipped,
|
||||
])->save();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($scheduleRun->started_at && $bulkRun->status === 'pending') {
|
||||
$bulkRun->forceFill(['status' => 'running'])->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function render(): \Illuminate\Contracts\View\View
|
||||
|
||||
@ -41,6 +41,11 @@ public function tenant(): BelongsTo
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function backupSet(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(BackupSet::class);
|
||||
|
||||
@ -8,8 +8,9 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class BackupScheduleDispatcher
|
||||
{
|
||||
@ -71,10 +72,19 @@ public function dispatchDue(?array $tenantIdentifiers = null): array
|
||||
'status' => BackupScheduleRun::STATUS_RUNNING,
|
||||
'summary' => null,
|
||||
]);
|
||||
} catch (QueryException $exception) {
|
||||
} catch (UniqueConstraintViolationException) {
|
||||
// Idempotency: unique (backup_schedule_id, scheduled_for)
|
||||
$skippedRuns++;
|
||||
|
||||
Log::debug('Backup schedule run already dispatched for slot.', [
|
||||
'schedule_id' => $schedule->id,
|
||||
'slot' => $slot->toDateTimeString(),
|
||||
]);
|
||||
|
||||
$schedule->forceFill([
|
||||
'next_run_at' => $this->scheduleTimeService->nextRunFor($schedule, $nowUtc),
|
||||
])->saveQuietly();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@ -109,8 +109,24 @@ public function recordSkippedWithReason(BulkOperationRun $run, string $itemId, s
|
||||
|
||||
public function complete(BulkOperationRun $run): void
|
||||
{
|
||||
$run->refresh();
|
||||
|
||||
if (! in_array($run->status, ['pending', 'running'], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$status = $run->failed > 0 ? 'completed_with_errors' : 'completed';
|
||||
$run->update(['status' => $status]);
|
||||
|
||||
$updated = BulkOperationRun::query()
|
||||
->whereKey($run->id)
|
||||
->whereIn('status', ['pending', 'running'])
|
||||
->update(['status' => $status]);
|
||||
|
||||
if ($updated === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$run->refresh();
|
||||
|
||||
$failureEntries = collect($run->failures ?? []);
|
||||
$failedReasons = $failureEntries
|
||||
|
||||
@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('backup_schedule_runs', function (Blueprint $table) {
|
||||
$table->foreignId('user_id')
|
||||
->nullable()
|
||||
->after('tenant_id')
|
||||
->constrained()
|
||||
->nullOnDelete();
|
||||
|
||||
$table->index(['user_id', 'created_at'], 'backup_schedule_runs_user_created');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('backup_schedule_runs', function (Blueprint $table) {
|
||||
$table->dropIndex('backup_schedule_runs_user_created');
|
||||
$table->dropConstrainedForeignId('user_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -13,12 +13,13 @@
|
||||
</h4>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
@if($run->status === 'pending')
|
||||
@php($isStalePending = $run->created_at->lt(now()->subSeconds(30)))
|
||||
<span class="inline-flex items-center">
|
||||
<svg class="animate-spin -ml-1 mr-1.5 h-3 w-3 text-primary-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Starting...
|
||||
{{ $isStalePending ? 'Queued…' : 'Starting...' }}
|
||||
</span>
|
||||
@elseif($run->status === 'running')
|
||||
<span class="inline-flex items-center">
|
||||
@ -28,6 +29,10 @@
|
||||
</svg>
|
||||
Processing...
|
||||
</span>
|
||||
@elseif(in_array($run->status, ['completed', 'completed_with_errors'], true))
|
||||
<span class="text-success-600 dark:text-success-400">Done</span>
|
||||
@elseif(in_array($run->status, ['failed', 'aborted'], true))
|
||||
<span class="text-danger-600 dark:text-danger-400">Failed</span>
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -88,6 +88,8 @@ ### Tests (Pest)
|
||||
- [X] T029 [P] [US3] Add feature test tests/Feature/BackupScheduling/ApplyRetentionJobTest.php (keeps last N backup_sets; soft-deletes older)
|
||||
- [X] T038 [P] [US1] Add feature test tests/Feature/BackupScheduling/BackupScheduleBulkDeleteTest.php (bulk delete action regression)
|
||||
- [X] T039 [P] [US1] Extend tests/Feature/BackupScheduling/BackupScheduleBulkDeleteTest.php (operator cannot bulk delete)
|
||||
- [X] T041 [P] [US3] Make manual dispatch actions idempotent under concurrency in app/Filament/Resources/BackupScheduleResource.php (avoid unique constraint 500); add regression in tests/Feature/BackupScheduling/RunNowRetryActionsTest.php
|
||||
- [X] T042 [P] [US2] Harden dispatcher idempotency in app/Services/BackupScheduling/BackupScheduleDispatcher.php (catch unique constraint only; treat as already dispatched, no side effects) and extend tests/Feature/BackupScheduling/DispatchIdempotencyTest.php
|
||||
|
||||
### Implementation
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
use App\Filament\Resources\BackupScheduleResource\Pages\EditBackupSchedule;
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\Tenant;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
@ -21,7 +22,11 @@
|
||||
'frequency' => 'daily',
|
||||
'time_of_day' => '01:00:00',
|
||||
'days_of_week' => null,
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'policy_types' => [
|
||||
'deviceConfiguration',
|
||||
'groupPolicyConfiguration',
|
||||
'settingsCatalogPolicy',
|
||||
],
|
||||
'include_foundations' => true,
|
||||
'retention_keep_last' => 30,
|
||||
]);
|
||||
@ -45,9 +50,34 @@
|
||||
->assertOk()
|
||||
->assertSee('Tenant A schedule')
|
||||
->assertSee('Device Configuration')
|
||||
->assertSee('more')
|
||||
->assertDontSee('Tenant B schedule');
|
||||
});
|
||||
|
||||
test('backup schedules listing shows next run in schedule timezone', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
|
||||
BackupSchedule::query()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Berlin schedule',
|
||||
'is_enabled' => true,
|
||||
'timezone' => 'Europe/Berlin',
|
||||
'frequency' => 'daily',
|
||||
'time_of_day' => '10:17:00',
|
||||
'days_of_week' => null,
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'include_foundations' => true,
|
||||
'retention_keep_last' => 30,
|
||||
'next_run_at' => CarbonImmutable::create(2026, 1, 5, 9, 17, 0, 'UTC'),
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$this->get(route('filament.admin.resources.backup-schedules.index', filamentTenantRouteParams($tenant)))
|
||||
->assertOk()
|
||||
->assertSee('Jan 5, 2026 10:17:00');
|
||||
});
|
||||
|
||||
test('backup schedules pages return 404 for unauthorized tenant', function () {
|
||||
[$user] = createUserWithTenant(role: 'manager');
|
||||
$unauthorizedTenant = Tenant::factory()->create();
|
||||
|
||||
@ -38,3 +38,44 @@
|
||||
|
||||
Bus::assertDispatchedTimes(RunBackupScheduleJob::class, 1);
|
||||
});
|
||||
|
||||
it('treats a unique constraint collision as already-dispatched and advances next_run_at', function () {
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 5, 10, 0, 30, 'UTC'));
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$schedule = BackupSchedule::query()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Daily 10:00',
|
||||
'is_enabled' => true,
|
||||
'timezone' => 'UTC',
|
||||
'frequency' => 'daily',
|
||||
'time_of_day' => '10:00:00',
|
||||
'days_of_week' => null,
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'include_foundations' => true,
|
||||
'retention_keep_last' => 30,
|
||||
'next_run_at' => null,
|
||||
]);
|
||||
|
||||
BackupScheduleRun::query()->create([
|
||||
'backup_schedule_id' => $schedule->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute()->toDateTimeString(),
|
||||
'status' => BackupScheduleRun::STATUS_RUNNING,
|
||||
'summary' => null,
|
||||
]);
|
||||
|
||||
Bus::fake();
|
||||
|
||||
$dispatcher = app(BackupScheduleDispatcher::class);
|
||||
$dispatcher->dispatchDue([$tenant->external_id]);
|
||||
|
||||
expect(BackupScheduleRun::query()->count())->toBe(1);
|
||||
Bus::assertNotDispatched(RunBackupScheduleJob::class);
|
||||
|
||||
$schedule->refresh();
|
||||
expect($schedule->next_run_at)->not->toBeNull();
|
||||
expect($schedule->next_run_at->toDateTimeString())->toBe('2026-01-06 10:00:00');
|
||||
});
|
||||
|
||||
@ -4,7 +4,9 @@
|
||||
use App\Jobs\RunBackupScheduleJob;
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupScheduleRun;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\User;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
@ -38,6 +40,15 @@
|
||||
|
||||
$run = BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->first();
|
||||
expect($run)->not->toBeNull();
|
||||
expect($run->user_id)->toBe($user->id);
|
||||
|
||||
expect(BulkOperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('user_id', $user->id)
|
||||
->where('resource', 'backup_schedule')
|
||||
->where('action', 'run')
|
||||
->count())
|
||||
->toBe(1);
|
||||
|
||||
Queue::assertPushed(RunBackupScheduleJob::class);
|
||||
|
||||
@ -45,6 +56,8 @@
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'notifiable_id' => $user->id,
|
||||
'notifiable_type' => User::class,
|
||||
'data->format' => 'filament',
|
||||
'data->title' => 'Run dispatched',
|
||||
]);
|
||||
});
|
||||
|
||||
@ -75,8 +88,25 @@
|
||||
expect(BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count())
|
||||
->toBe(1);
|
||||
|
||||
$run = BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->first();
|
||||
expect($run)->not->toBeNull();
|
||||
expect($run->user_id)->toBe($user->id);
|
||||
|
||||
expect(BulkOperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('user_id', $user->id)
|
||||
->where('resource', 'backup_schedule')
|
||||
->where('action', 'retry')
|
||||
->count())
|
||||
->toBe(1);
|
||||
|
||||
Queue::assertPushed(RunBackupScheduleJob::class);
|
||||
$this->assertDatabaseCount('notifications', 1);
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'notifiable_id' => $user->id,
|
||||
'data->format' => 'filament',
|
||||
'data->title' => 'Retry dispatched',
|
||||
]);
|
||||
});
|
||||
|
||||
test('readonly cannot dispatch run now or retry', function () {
|
||||
@ -156,8 +186,24 @@
|
||||
expect(BackupScheduleRun::query()->whereIn('backup_schedule_id', [$scheduleA->id, $scheduleB->id])->count())
|
||||
->toBe(2);
|
||||
|
||||
expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleA->id)->value('user_id'))->toBe($user->id);
|
||||
expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleB->id)->value('user_id'))->toBe($user->id);
|
||||
|
||||
expect(BulkOperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('user_id', $user->id)
|
||||
->where('resource', 'backup_schedule')
|
||||
->where('action', 'run')
|
||||
->count())
|
||||
->toBe(1);
|
||||
|
||||
Queue::assertPushed(RunBackupScheduleJob::class, 2);
|
||||
$this->assertDatabaseCount('notifications', 1);
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'notifiable_id' => $user->id,
|
||||
'data->format' => 'filament',
|
||||
'data->title' => 'Runs dispatched',
|
||||
]);
|
||||
});
|
||||
|
||||
test('operator can bulk retry and it persists a database notification', function () {
|
||||
@ -200,6 +246,86 @@
|
||||
expect(BackupScheduleRun::query()->whereIn('backup_schedule_id', [$scheduleA->id, $scheduleB->id])->count())
|
||||
->toBe(2);
|
||||
|
||||
expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleA->id)->value('user_id'))->toBe($user->id);
|
||||
expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleB->id)->value('user_id'))->toBe($user->id);
|
||||
|
||||
expect(BulkOperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('user_id', $user->id)
|
||||
->where('resource', 'backup_schedule')
|
||||
->where('action', 'retry')
|
||||
->count())
|
||||
->toBe(1);
|
||||
|
||||
Queue::assertPushed(RunBackupScheduleJob::class, 2);
|
||||
$this->assertDatabaseCount('notifications', 1);
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'notifiable_id' => $user->id,
|
||||
'data->format' => 'filament',
|
||||
'data->title' => 'Retries dispatched',
|
||||
]);
|
||||
});
|
||||
|
||||
test('operator can bulk retry even if a run already exists for this minute', function () {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||
|
||||
$scheduleA = BackupSchedule::query()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Nightly A',
|
||||
'is_enabled' => true,
|
||||
'timezone' => 'UTC',
|
||||
'frequency' => 'daily',
|
||||
'time_of_day' => '01:00:00',
|
||||
'days_of_week' => null,
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'include_foundations' => true,
|
||||
'retention_keep_last' => 30,
|
||||
]);
|
||||
|
||||
$scheduleB = BackupSchedule::query()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Nightly B',
|
||||
'is_enabled' => true,
|
||||
'timezone' => 'UTC',
|
||||
'frequency' => 'daily',
|
||||
'time_of_day' => '02:00:00',
|
||||
'days_of_week' => null,
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'include_foundations' => true,
|
||||
'retention_keep_last' => 30,
|
||||
]);
|
||||
|
||||
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
|
||||
BackupScheduleRun::query()->create([
|
||||
'backup_schedule_id' => $scheduleA->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||
'status' => BackupScheduleRun::STATUS_RUNNING,
|
||||
'summary' => null,
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(ListBackupSchedules::class)
|
||||
->callTableBulkAction('bulk_retry', collect([$scheduleA, $scheduleB]));
|
||||
|
||||
expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleA->id)->count())
|
||||
->toBe(2);
|
||||
|
||||
$newRunA = BackupScheduleRun::query()
|
||||
->where('backup_schedule_id', $scheduleA->id)
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
expect($newRunA)->not->toBeNull();
|
||||
expect($newRunA->scheduled_for->setTimezone('UTC')->toDateTimeString())
|
||||
->toBe($scheduledFor->addMinute()->toDateTimeString());
|
||||
|
||||
expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleB->id)->count())
|
||||
->toBe(1);
|
||||
|
||||
Queue::assertPushed(RunBackupScheduleJob::class, 2);
|
||||
});
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\BulkOperationProgress;
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupScheduleRun;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
@ -30,6 +32,7 @@
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => $user->id,
|
||||
'status' => 'completed',
|
||||
'updated_at' => now()->subMinutes(5),
|
||||
]);
|
||||
|
||||
// Other user's op (should not show)
|
||||
@ -47,3 +50,56 @@
|
||||
->assertSee('Delete Policy')
|
||||
->assertSee('50 / 100');
|
||||
});
|
||||
|
||||
test('progress widget reconciles stale pending backup schedule runs', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$tenant->makeCurrent();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$schedule = BackupSchedule::query()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Nightly',
|
||||
'is_enabled' => true,
|
||||
'timezone' => 'UTC',
|
||||
'frequency' => 'daily',
|
||||
'time_of_day' => '01:00:00',
|
||||
'days_of_week' => null,
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'include_foundations' => true,
|
||||
'retention_keep_last' => 30,
|
||||
'next_run_at' => now()->addHour(),
|
||||
]);
|
||||
|
||||
$bulkRun = BulkOperationRun::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => $user->id,
|
||||
'status' => 'pending',
|
||||
'resource' => 'backup_schedule',
|
||||
'action' => 'run',
|
||||
'total_items' => 1,
|
||||
'processed_items' => 0,
|
||||
'item_ids' => [(string) $schedule->id],
|
||||
'created_at' => now()->subMinutes(2),
|
||||
'updated_at' => now()->subMinutes(2),
|
||||
]);
|
||||
|
||||
BackupScheduleRun::query()->create([
|
||||
'backup_schedule_id' => $schedule->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => $user->id,
|
||||
'scheduled_for' => now()->startOfMinute(),
|
||||
'started_at' => now()->subMinute(),
|
||||
'finished_at' => now(),
|
||||
'status' => BackupScheduleRun::STATUS_SUCCESS,
|
||||
'summary' => null,
|
||||
]);
|
||||
|
||||
auth()->login($user);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(BulkOperationProgress::class)
|
||||
->assertSee('Run Backup schedule')
|
||||
->assertSee('1 / 1');
|
||||
|
||||
expect($bulkRun->refresh()->status)->toBe('completed');
|
||||
});
|
||||
|
||||
143
tests/Feature/Console/PurgeNonPersistentDataCommandTest.php
Normal file
143
tests/Feature/Console/PurgeNonPersistentDataCommandTest.php
Normal file
@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupScheduleRun;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\SettingsCatalogCategory;
|
||||
use App\Models\SettingsCatalogDefinition;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('purges non-persistent tenant data without touching persistent catalog data', function () {
|
||||
$tenantA = Tenant::factory()->create(['name' => 'Tenant A']);
|
||||
$tenantB = Tenant::factory()->create(['name' => 'Tenant B']);
|
||||
|
||||
SettingsCatalogCategory::create([
|
||||
'category_id' => 'cat-1',
|
||||
'display_name' => 'Account Management',
|
||||
'description' => null,
|
||||
]);
|
||||
|
||||
SettingsCatalogDefinition::create([
|
||||
'definition_id' => 'def-1',
|
||||
'display_name' => 'Deletion Policy',
|
||||
'description' => null,
|
||||
'help_text' => null,
|
||||
'category_id' => 'cat-1',
|
||||
'ux_behavior' => null,
|
||||
'raw' => [],
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$policyA = Policy::factory()->create(['tenant_id' => $tenantA->id]);
|
||||
$policyB = Policy::factory()->create(['tenant_id' => $tenantB->id]);
|
||||
|
||||
PolicyVersion::factory()->create([
|
||||
'tenant_id' => $tenantA->id,
|
||||
'policy_id' => $policyA->id,
|
||||
'version_number' => 1,
|
||||
]);
|
||||
|
||||
PolicyVersion::factory()->create([
|
||||
'tenant_id' => $tenantB->id,
|
||||
'policy_id' => $policyB->id,
|
||||
'version_number' => 1,
|
||||
]);
|
||||
|
||||
$backupSetA = BackupSet::factory()->create(['tenant_id' => $tenantA->id]);
|
||||
BackupItem::factory()->create([
|
||||
'tenant_id' => $tenantA->id,
|
||||
'backup_set_id' => $backupSetA->id,
|
||||
'policy_id' => $policyA->id,
|
||||
]);
|
||||
|
||||
RestoreRun::factory()->create([
|
||||
'tenant_id' => $tenantA->id,
|
||||
'backup_set_id' => $backupSetA->id,
|
||||
]);
|
||||
|
||||
AuditLog::create([
|
||||
'tenant_id' => $tenantA->id,
|
||||
'actor_id' => null,
|
||||
'actor_email' => null,
|
||||
'actor_name' => null,
|
||||
'action' => 'test.action',
|
||||
'resource_type' => null,
|
||||
'resource_id' => null,
|
||||
'status' => 'success',
|
||||
'metadata' => null,
|
||||
'recorded_at' => now(),
|
||||
]);
|
||||
|
||||
BulkOperationRun::factory()->create([
|
||||
'tenant_id' => $tenantA->id,
|
||||
'user_id' => $user->id,
|
||||
'status' => 'completed',
|
||||
]);
|
||||
|
||||
$scheduleA = BackupSchedule::create([
|
||||
'tenant_id' => $tenantA->id,
|
||||
'name' => 'Schedule A',
|
||||
'is_enabled' => true,
|
||||
'timezone' => 'UTC',
|
||||
'frequency' => 'daily',
|
||||
'time_of_day' => '10:00:00',
|
||||
'days_of_week' => null,
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'include_foundations' => true,
|
||||
'retention_keep_last' => 30,
|
||||
'last_run_at' => null,
|
||||
'last_run_status' => null,
|
||||
'next_run_at' => now()->addHour(),
|
||||
]);
|
||||
|
||||
BackupScheduleRun::create([
|
||||
'backup_schedule_id' => $scheduleA->id,
|
||||
'tenant_id' => $tenantA->id,
|
||||
'scheduled_for' => now()->startOfMinute(),
|
||||
'started_at' => null,
|
||||
'finished_at' => null,
|
||||
'status' => BackupScheduleRun::STATUS_SUCCESS,
|
||||
'summary' => null,
|
||||
'error_code' => null,
|
||||
'error_message' => null,
|
||||
'backup_set_id' => $backupSetA->id,
|
||||
]);
|
||||
|
||||
expect(Policy::query()->where('tenant_id', $tenantA->id)->count())->toBeGreaterThan(0);
|
||||
expect(BackupSet::withTrashed()->where('tenant_id', $tenantA->id)->count())->toBeGreaterThan(0);
|
||||
expect(BackupScheduleRun::query()->where('tenant_id', $tenantA->id)->count())->toBeGreaterThan(0);
|
||||
|
||||
$this->artisan('tenantpilot:purge-nonpersistent', [
|
||||
'tenant' => $tenantA->id,
|
||||
'--force' => true,
|
||||
'--no-interaction' => true,
|
||||
])->assertSuccessful();
|
||||
|
||||
expect(Policy::query()->where('tenant_id', $tenantA->id)->count())->toBe(0);
|
||||
expect(PolicyVersion::withTrashed()->where('tenant_id', $tenantA->id)->count())->toBe(0);
|
||||
expect(BackupItem::withTrashed()->where('tenant_id', $tenantA->id)->count())->toBe(0);
|
||||
expect(BackupSet::withTrashed()->where('tenant_id', $tenantA->id)->count())->toBe(0);
|
||||
expect(RestoreRun::withTrashed()->where('tenant_id', $tenantA->id)->count())->toBe(0);
|
||||
expect(AuditLog::query()->where('tenant_id', $tenantA->id)->count())->toBe(0);
|
||||
expect(BulkOperationRun::query()->where('tenant_id', $tenantA->id)->count())->toBe(0);
|
||||
expect(BackupScheduleRun::query()->where('tenant_id', $tenantA->id)->count())->toBe(0);
|
||||
|
||||
expect(BackupSchedule::query()->where('tenant_id', $tenantA->id)->count())->toBe(0);
|
||||
|
||||
expect(Policy::query()->where('tenant_id', $tenantB->id)->count())->toBe(1);
|
||||
expect(PolicyVersion::withTrashed()->where('tenant_id', $tenantB->id)->count())->toBe(1);
|
||||
|
||||
expect(SettingsCatalogCategory::query()->count())->toBe(1);
|
||||
expect(SettingsCatalogDefinition::query()->count())->toBe(1);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user