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`).
|
- 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.
|
- 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
|
### Configuration
|
||||||
|
|
||||||
- `TENANTPILOT_BULK_CHUNK_SIZE` (default `10`): job refresh/progress chunk size.
|
- `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\BackupScheduleRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Notifications\BackupScheduleRunDispatchedNotification;
|
|
||||||
use App\Rules\SupportedPolicyTypesRule;
|
use App\Rules\SupportedPolicyTypesRule;
|
||||||
use App\Services\BackupScheduling\PolicyTypeResolver;
|
use App\Services\BackupScheduling\PolicyTypeResolver;
|
||||||
use App\Services\BackupScheduling\ScheduleTimeService;
|
use App\Services\BackupScheduling\ScheduleTimeService;
|
||||||
|
use App\Services\BulkOperationService;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Support\TenantRole;
|
use App\Support\TenantRole;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
@ -30,6 +30,7 @@
|
|||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Notifications\Events\DatabaseNotificationsSent;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
use Filament\Schemas\Components\Utilities\Get;
|
use Filament\Schemas\Components\Utilities\Get;
|
||||||
@ -41,6 +42,7 @@
|
|||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\UniqueConstraintViolationException;
|
||||||
use Illuminate\Support\Facades\Bus;
|
use Illuminate\Support\Facades\Bus;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
@ -200,50 +202,8 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
TextColumn::make('policy_types')
|
TextColumn::make('policy_types')
|
||||||
->label('Policy types')
|
->label('Policy types')
|
||||||
->wrap()
|
->getStateUsing(fn (BackupSchedule $record): string => static::policyTypesPreviewLabel($record))
|
||||||
->getStateUsing(function (BackupSchedule $record): string {
|
->tooltip(fn (BackupSchedule $record): string => static::policyTypesFullLabel($record)),
|
||||||
$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);
|
|
||||||
}),
|
|
||||||
|
|
||||||
TextColumn::make('retention_keep_last')
|
TextColumn::make('retention_keep_last')
|
||||||
->label('Retention')
|
->label('Retention')
|
||||||
@ -278,7 +238,21 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
TextColumn::make('next_run_at')
|
TextColumn::make('next_run_at')
|
||||||
->label('Next run')
|
->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(),
|
->sortable(),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
@ -318,28 +292,37 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
$userId = auth()->id();
|
||||||
|
$userModel = $user instanceof User ? $user : ($userId ? User::query()->find($userId) : null);
|
||||||
|
|
||||||
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
|
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
|
||||||
|
$run = null;
|
||||||
|
|
||||||
for ($i = 0; $i < 5; $i++) {
|
for ($i = 0; $i < 5; $i++) {
|
||||||
$exists = BackupScheduleRun::query()
|
try {
|
||||||
->where('backup_schedule_id', $record->id)
|
|
||||||
->where('scheduled_for', $scheduledFor)
|
|
||||||
->exists();
|
|
||||||
|
|
||||||
if (! $exists) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
$scheduledFor = $scheduledFor->addMinute();
|
|
||||||
}
|
|
||||||
|
|
||||||
$run = BackupScheduleRun::create([
|
$run = BackupScheduleRun::create([
|
||||||
'backup_schedule_id' => $record->id,
|
'backup_schedule_id' => $record->id,
|
||||||
'tenant_id' => $tenant->getKey(),
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'user_id' => $userId,
|
||||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||||
'status' => BackupScheduleRun::STATUS_RUNNING,
|
'status' => BackupScheduleRun::STATUS_RUNNING,
|
||||||
'summary' => null,
|
'summary' => null,
|
||||||
]);
|
]);
|
||||||
|
break;
|
||||||
|
} catch (UniqueConstraintViolationException) {
|
||||||
|
$scheduledFor = $scheduledFor->addMinute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
app(AuditLogger::class)->log(
|
||||||
tenant: $tenant,
|
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) {
|
if ($userModel instanceof User) {
|
||||||
$user->notify(new BackupScheduleRunDispatchedNotification([
|
$bulkRunId = app(BulkOperationService::class)
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
->createRun(
|
||||||
'backup_schedule_id' => (int) $record->id,
|
tenant: $tenant,
|
||||||
'backup_schedule_run_id' => (int) $run->id,
|
user: $userModel,
|
||||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
resource: 'backup_schedule',
|
||||||
'trigger' => 'run_now',
|
action: 'run',
|
||||||
]));
|
itemIds: [(string) $record->id],
|
||||||
|
totalItems: 1,
|
||||||
|
)
|
||||||
|
->id;
|
||||||
}
|
}
|
||||||
|
|
||||||
Notification::make()
|
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRunId));
|
||||||
|
|
||||||
|
$notification = Notification::make()
|
||||||
->title('Run dispatched')
|
->title('Run dispatched')
|
||||||
->body('The backup run has been queued.')
|
->body('The backup run has been queued.')
|
||||||
->success()
|
->success();
|
||||||
->send();
|
|
||||||
|
if ($userModel instanceof User) {
|
||||||
|
$userModel->notifyNow($notification->toDatabase());
|
||||||
|
DatabaseNotificationsSent::dispatch($userModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
$notification->send();
|
||||||
}),
|
}),
|
||||||
Action::make('retry')
|
Action::make('retry')
|
||||||
->label('Retry')
|
->label('Retry')
|
||||||
@ -385,28 +379,37 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
$userId = auth()->id();
|
||||||
|
$userModel = $user instanceof User ? $user : ($userId ? User::query()->find($userId) : null);
|
||||||
|
|
||||||
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
|
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
|
||||||
|
$run = null;
|
||||||
|
|
||||||
for ($i = 0; $i < 5; $i++) {
|
for ($i = 0; $i < 5; $i++) {
|
||||||
$exists = BackupScheduleRun::query()
|
try {
|
||||||
->where('backup_schedule_id', $record->id)
|
|
||||||
->where('scheduled_for', $scheduledFor)
|
|
||||||
->exists();
|
|
||||||
|
|
||||||
if (! $exists) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
$scheduledFor = $scheduledFor->addMinute();
|
|
||||||
}
|
|
||||||
|
|
||||||
$run = BackupScheduleRun::create([
|
$run = BackupScheduleRun::create([
|
||||||
'backup_schedule_id' => $record->id,
|
'backup_schedule_id' => $record->id,
|
||||||
'tenant_id' => $tenant->getKey(),
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'user_id' => $userId,
|
||||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||||
'status' => BackupScheduleRun::STATUS_RUNNING,
|
'status' => BackupScheduleRun::STATUS_RUNNING,
|
||||||
'summary' => null,
|
'summary' => null,
|
||||||
]);
|
]);
|
||||||
|
break;
|
||||||
|
} catch (UniqueConstraintViolationException) {
|
||||||
|
$scheduledFor = $scheduledFor->addMinute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
app(AuditLogger::class)->log(
|
||||||
tenant: $tenant,
|
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) {
|
if ($userModel instanceof User) {
|
||||||
$user->notify(new BackupScheduleRunDispatchedNotification([
|
$bulkRunId = app(BulkOperationService::class)
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
->createRun(
|
||||||
'backup_schedule_id' => (int) $record->id,
|
tenant: $tenant,
|
||||||
'backup_schedule_run_id' => (int) $run->id,
|
user: $userModel,
|
||||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
resource: 'backup_schedule',
|
||||||
'trigger' => 'retry',
|
action: 'retry',
|
||||||
]));
|
itemIds: [(string) $record->id],
|
||||||
|
totalItems: 1,
|
||||||
|
)
|
||||||
|
->id;
|
||||||
}
|
}
|
||||||
|
|
||||||
Notification::make()
|
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRunId));
|
||||||
|
|
||||||
|
$notification = Notification::make()
|
||||||
->title('Retry dispatched')
|
->title('Retry dispatched')
|
||||||
->body('A new backup run has been queued.')
|
->body('A new backup run has been queued.')
|
||||||
->success()
|
->success();
|
||||||
->send();
|
|
||||||
|
if ($userModel instanceof User) {
|
||||||
|
$userModel->notifyNow($notification->toDatabase());
|
||||||
|
DatabaseNotificationsSent::dispatch($userModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
$notification->send();
|
||||||
}),
|
}),
|
||||||
EditAction::make()
|
EditAction::make()
|
||||||
->visible(fn (): bool => static::currentTenantRole()?->canManageBackupSchedules() ?? false),
|
->visible(fn (): bool => static::currentTenantRole()?->canManageBackupSchedules() ?? false),
|
||||||
@ -463,33 +477,47 @@ public static function table(Table $table): Table
|
|||||||
}
|
}
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$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 = [];
|
$createdRunIds = [];
|
||||||
|
|
||||||
/** @var BackupSchedule $record */
|
/** @var BackupSchedule $record */
|
||||||
foreach ($records as $record) {
|
foreach ($records as $record) {
|
||||||
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
|
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
|
||||||
|
$run = null;
|
||||||
|
|
||||||
for ($i = 0; $i < 5; $i++) {
|
for ($i = 0; $i < 5; $i++) {
|
||||||
$exists = BackupScheduleRun::query()
|
try {
|
||||||
->where('backup_schedule_id', $record->id)
|
|
||||||
->where('scheduled_for', $scheduledFor)
|
|
||||||
->exists();
|
|
||||||
|
|
||||||
if (! $exists) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
$scheduledFor = $scheduledFor->addMinute();
|
|
||||||
}
|
|
||||||
|
|
||||||
$run = BackupScheduleRun::create([
|
$run = BackupScheduleRun::create([
|
||||||
'backup_schedule_id' => $record->id,
|
'backup_schedule_id' => $record->id,
|
||||||
'tenant_id' => $tenant->getKey(),
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'user_id' => $userId,
|
||||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||||
'status' => BackupScheduleRun::STATUS_RUNNING,
|
'status' => BackupScheduleRun::STATUS_RUNNING,
|
||||||
'summary' => null,
|
'summary' => null,
|
||||||
]);
|
]);
|
||||||
|
break;
|
||||||
|
} catch (UniqueConstraintViolationException) {
|
||||||
|
$scheduledFor = $scheduledFor->addMinute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $run instanceof BackupScheduleRun) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$createdRunIds[] = (int) $run->id;
|
$createdRunIds[] = (int) $run->id;
|
||||||
|
|
||||||
@ -505,28 +533,30 @@ public static function table(Table $table): Table
|
|||||||
'backup_schedule_run_id' => $run->id,
|
'backup_schedule_run_id' => $run->id,
|
||||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||||
'trigger' => 'bulk_run_now',
|
'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) {
|
if ($user instanceof User) {
|
||||||
$user->notify(new BackupScheduleRunDispatchedNotification([
|
$user->notifyNow($notification->toDatabase());
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
DatabaseNotificationsSent::dispatch($user);
|
||||||
'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',
|
|
||||||
]));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Notification::make()
|
$notification->send();
|
||||||
->title('Runs dispatched')
|
|
||||||
->body(sprintf('Queued %d run(s).', count($createdRunIds)))
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
}),
|
}),
|
||||||
BulkAction::make('bulk_retry')
|
BulkAction::make('bulk_retry')
|
||||||
->label('Retry')
|
->label('Retry')
|
||||||
@ -541,33 +571,47 @@ public static function table(Table $table): Table
|
|||||||
}
|
}
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$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 = [];
|
$createdRunIds = [];
|
||||||
|
|
||||||
/** @var BackupSchedule $record */
|
/** @var BackupSchedule $record */
|
||||||
foreach ($records as $record) {
|
foreach ($records as $record) {
|
||||||
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
|
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
|
||||||
|
$run = null;
|
||||||
|
|
||||||
for ($i = 0; $i < 5; $i++) {
|
for ($i = 0; $i < 5; $i++) {
|
||||||
$exists = BackupScheduleRun::query()
|
try {
|
||||||
->where('backup_schedule_id', $record->id)
|
|
||||||
->where('scheduled_for', $scheduledFor)
|
|
||||||
->exists();
|
|
||||||
|
|
||||||
if (! $exists) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
$scheduledFor = $scheduledFor->addMinute();
|
|
||||||
}
|
|
||||||
|
|
||||||
$run = BackupScheduleRun::create([
|
$run = BackupScheduleRun::create([
|
||||||
'backup_schedule_id' => $record->id,
|
'backup_schedule_id' => $record->id,
|
||||||
'tenant_id' => $tenant->getKey(),
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'user_id' => $userId,
|
||||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||||
'status' => BackupScheduleRun::STATUS_RUNNING,
|
'status' => BackupScheduleRun::STATUS_RUNNING,
|
||||||
'summary' => null,
|
'summary' => null,
|
||||||
]);
|
]);
|
||||||
|
break;
|
||||||
|
} catch (UniqueConstraintViolationException) {
|
||||||
|
$scheduledFor = $scheduledFor->addMinute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $run instanceof BackupScheduleRun) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$createdRunIds[] = (int) $run->id;
|
$createdRunIds[] = (int) $run->id;
|
||||||
|
|
||||||
@ -583,28 +627,30 @@ public static function table(Table $table): Table
|
|||||||
'backup_schedule_run_id' => $run->id,
|
'backup_schedule_run_id' => $run->id,
|
||||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||||
'trigger' => 'bulk_retry',
|
'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) {
|
if ($user instanceof User) {
|
||||||
$user->notify(new BackupScheduleRunDispatchedNotification([
|
$user->notifyNow($notification->toDatabase());
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
DatabaseNotificationsSent::dispatch($user);
|
||||||
'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',
|
|
||||||
]));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Notification::make()
|
$notification->send();
|
||||||
->title('Retries dispatched')
|
|
||||||
->body(sprintf('Queued %d run(s).', count($createdRunIds)))
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
}),
|
}),
|
||||||
DeleteBulkAction::make('bulk_delete')
|
DeleteBulkAction::make('bulk_delete')
|
||||||
->visible(fn (): bool => static::currentTenantRole()?->canManageBackupSchedules() ?? false),
|
->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
|
public static function ensurePolicyTypes(array $data): array
|
||||||
{
|
{
|
||||||
$types = array_values((array) ($data['policy_types'] ?? []));
|
$types = array_values((array) ($data['policy_types'] ?? []));
|
||||||
|
|||||||
@ -4,13 +4,17 @@
|
|||||||
|
|
||||||
use App\Models\BackupSchedule;
|
use App\Models\BackupSchedule;
|
||||||
use App\Models\BackupScheduleRun;
|
use App\Models\BackupScheduleRun;
|
||||||
|
use App\Models\BulkOperationRun;
|
||||||
use App\Services\BackupScheduling\PolicyTypeResolver;
|
use App\Services\BackupScheduling\PolicyTypeResolver;
|
||||||
use App\Services\BackupScheduling\RunErrorMapper;
|
use App\Services\BackupScheduling\RunErrorMapper;
|
||||||
use App\Services\BackupScheduling\ScheduleTimeService;
|
use App\Services\BackupScheduling\ScheduleTimeService;
|
||||||
|
use App\Services\BulkOperationService;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\Intune\BackupService;
|
use App\Services\Intune\BackupService;
|
||||||
use App\Services\Intune\PolicySyncService;
|
use App\Services\Intune\PolicySyncService;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
|
use Filament\Notifications\Events\DatabaseNotificationsSent;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
@ -26,7 +30,10 @@ class RunBackupScheduleJob implements ShouldQueue
|
|||||||
|
|
||||||
public int $tries = 3;
|
public int $tries = 3;
|
||||||
|
|
||||||
public function __construct(public int $backupScheduleRunId) {}
|
public function __construct(
|
||||||
|
public int $backupScheduleRunId,
|
||||||
|
public ?int $bulkRunId = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
public function handle(
|
public function handle(
|
||||||
PolicySyncService $policySyncService,
|
PolicySyncService $policySyncService,
|
||||||
@ -35,15 +42,31 @@ public function handle(
|
|||||||
ScheduleTimeService $scheduleTimeService,
|
ScheduleTimeService $scheduleTimeService,
|
||||||
AuditLogger $auditLogger,
|
AuditLogger $auditLogger,
|
||||||
RunErrorMapper $errorMapper,
|
RunErrorMapper $errorMapper,
|
||||||
|
BulkOperationService $bulkOperationService,
|
||||||
): void {
|
): void {
|
||||||
$run = BackupScheduleRun::query()
|
$run = BackupScheduleRun::query()
|
||||||
->with(['schedule', 'tenant'])
|
->with(['schedule', 'tenant', 'user'])
|
||||||
->find($this->backupScheduleRunId);
|
->find($this->backupScheduleRunId);
|
||||||
|
|
||||||
if (! $run) {
|
if (! $run) {
|
||||||
return;
|
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;
|
$schedule = $run->schedule;
|
||||||
|
|
||||||
if (! $schedule instanceof BackupSchedule) {
|
if (! $schedule instanceof BackupSchedule) {
|
||||||
@ -81,6 +104,7 @@ public function handle(
|
|||||||
errorMessage: 'Another run is already in progress for this schedule.',
|
errorMessage: 'Another run is already in progress for this schedule.',
|
||||||
summary: ['reason' => 'concurrent_run'],
|
summary: ['reason' => 'concurrent_run'],
|
||||||
scheduleTimeService: $scheduleTimeService,
|
scheduleTimeService: $scheduleTimeService,
|
||||||
|
bulkRunId: $this->bulkRunId,
|
||||||
);
|
);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@ -94,6 +118,8 @@ public function handle(
|
|||||||
'status' => BackupScheduleRun::STATUS_RUNNING,
|
'status' => BackupScheduleRun::STATUS_RUNNING,
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
|
$this->notifyRunStarted($run, $schedule);
|
||||||
|
|
||||||
$auditLogger->log(
|
$auditLogger->log(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
action: 'backup_schedule.run_started',
|
action: 'backup_schedule.run_started',
|
||||||
@ -124,6 +150,7 @@ public function handle(
|
|||||||
'unknown_policy_types' => $unknownTypes,
|
'unknown_policy_types' => $unknownTypes,
|
||||||
],
|
],
|
||||||
scheduleTimeService: $scheduleTimeService,
|
scheduleTimeService: $scheduleTimeService,
|
||||||
|
bulkRunId: $this->bulkRunId,
|
||||||
);
|
);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@ -182,6 +209,7 @@ public function handle(
|
|||||||
summary: $summary,
|
summary: $summary,
|
||||||
scheduleTimeService: $scheduleTimeService,
|
scheduleTimeService: $scheduleTimeService,
|
||||||
backupSetId: (string) $backupSet->id,
|
backupSetId: (string) $backupSet->id,
|
||||||
|
bulkRunId: $this->bulkRunId,
|
||||||
);
|
);
|
||||||
|
|
||||||
$auditLogger->log(
|
$auditLogger->log(
|
||||||
@ -220,6 +248,7 @@ public function handle(
|
|||||||
'attempt' => $attempt,
|
'attempt' => $attempt,
|
||||||
],
|
],
|
||||||
scheduleTimeService: $scheduleTimeService,
|
scheduleTimeService: $scheduleTimeService,
|
||||||
|
bulkRunId: $this->bulkRunId,
|
||||||
);
|
);
|
||||||
|
|
||||||
$auditLogger->log(
|
$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(
|
private function finishRun(
|
||||||
BackupScheduleRun $run,
|
BackupScheduleRun $run,
|
||||||
BackupSchedule $schedule,
|
BackupSchedule $schedule,
|
||||||
@ -250,6 +329,7 @@ private function finishRun(
|
|||||||
array $summary,
|
array $summary,
|
||||||
ScheduleTimeService $scheduleTimeService,
|
ScheduleTimeService $scheduleTimeService,
|
||||||
?string $backupSetId = null,
|
?string $backupSetId = null,
|
||||||
|
?int $bulkRunId = null,
|
||||||
): void {
|
): void {
|
||||||
$nowUtc = CarbonImmutable::now('UTC');
|
$nowUtc = CarbonImmutable::now('UTC');
|
||||||
|
|
||||||
@ -268,6 +348,50 @@ private function finishRun(
|
|||||||
'next_run_at' => $scheduleTimeService->nextRunFor($schedule, $nowUtc),
|
'next_run_at' => $scheduleTimeService->nextRunFor($schedule, $nowUtc),
|
||||||
])->saveQuietly();
|
])->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)) {
|
if ($backupSetId && in_array($status, [BackupScheduleRun::STATUS_SUCCESS, BackupScheduleRun::STATUS_PARTIAL], true)) {
|
||||||
Bus::dispatch(new ApplyBackupScheduleRetentionJob($schedule->id));
|
Bus::dispatch(new ApplyBackupScheduleRetentionJob($schedule->id));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Livewire;
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Models\BackupScheduleRun;
|
||||||
use App\Models\BulkOperationRun;
|
use App\Models\BulkOperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
use Livewire\Attributes\Computed;
|
use Livewire\Attributes\Computed;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
@ -13,9 +15,12 @@ class BulkOperationProgress extends Component
|
|||||||
|
|
||||||
public int $pollSeconds = 3;
|
public int $pollSeconds = 3;
|
||||||
|
|
||||||
|
public int $recentFinishedSeconds = 12;
|
||||||
|
|
||||||
public function mount()
|
public function mount()
|
||||||
{
|
{
|
||||||
$this->pollSeconds = max(1, min(10, (int) config('tenantpilot.bulk_operations.poll_interval_seconds', 3)));
|
$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();
|
$this->loadRuns();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -35,12 +40,102 @@ public function loadRuns()
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$recentThreshold = now()->subSeconds($this->recentFinishedSeconds);
|
||||||
|
|
||||||
$this->runs = BulkOperationRun::query()
|
$this->runs = BulkOperationRun::query()
|
||||||
->where('tenant_id', $tenant->id)
|
->where('tenant_id', $tenant->id)
|
||||||
->where('user_id', auth()->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')
|
->orderByDesc('created_at')
|
||||||
->get();
|
->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
|
public function render(): \Illuminate\Contracts\View\View
|
||||||
|
|||||||
@ -41,6 +41,11 @@ public function tenant(): BelongsTo
|
|||||||
return $this->belongsTo(Tenant::class);
|
return $this->belongsTo(Tenant::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function backupSet(): BelongsTo
|
public function backupSet(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(BackupSet::class);
|
return $this->belongsTo(BackupSet::class);
|
||||||
|
|||||||
@ -8,8 +8,9 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Database\QueryException;
|
use Illuminate\Database\UniqueConstraintViolationException;
|
||||||
use Illuminate\Support\Facades\Bus;
|
use Illuminate\Support\Facades\Bus;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class BackupScheduleDispatcher
|
class BackupScheduleDispatcher
|
||||||
{
|
{
|
||||||
@ -71,10 +72,19 @@ public function dispatchDue(?array $tenantIdentifiers = null): array
|
|||||||
'status' => BackupScheduleRun::STATUS_RUNNING,
|
'status' => BackupScheduleRun::STATUS_RUNNING,
|
||||||
'summary' => null,
|
'summary' => null,
|
||||||
]);
|
]);
|
||||||
} catch (QueryException $exception) {
|
} catch (UniqueConstraintViolationException) {
|
||||||
// Idempotency: unique (backup_schedule_id, scheduled_for)
|
// Idempotency: unique (backup_schedule_id, scheduled_for)
|
||||||
$skippedRuns++;
|
$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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -109,8 +109,24 @@ public function recordSkippedWithReason(BulkOperationRun $run, string $itemId, s
|
|||||||
|
|
||||||
public function complete(BulkOperationRun $run): void
|
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';
|
$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 ?? []);
|
$failureEntries = collect($run->failures ?? []);
|
||||||
$failedReasons = $failureEntries
|
$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>
|
</h4>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
@if($run->status === 'pending')
|
@if($run->status === 'pending')
|
||||||
|
@php($isStalePending = $run->created_at->lt(now()->subSeconds(30)))
|
||||||
<span class="inline-flex items-center">
|
<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">
|
<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>
|
<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>
|
<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>
|
</svg>
|
||||||
Starting...
|
{{ $isStalePending ? 'Queued…' : 'Starting...' }}
|
||||||
</span>
|
</span>
|
||||||
@elseif($run->status === 'running')
|
@elseif($run->status === 'running')
|
||||||
<span class="inline-flex items-center">
|
<span class="inline-flex items-center">
|
||||||
@ -28,6 +29,10 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Processing...
|
Processing...
|
||||||
</span>
|
</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
|
@endif
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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] 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] 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] 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
|
### Implementation
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
use App\Filament\Resources\BackupScheduleResource\Pages\EditBackupSchedule;
|
use App\Filament\Resources\BackupScheduleResource\Pages\EditBackupSchedule;
|
||||||
use App\Models\BackupSchedule;
|
use App\Models\BackupSchedule;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
@ -21,7 +22,11 @@
|
|||||||
'frequency' => 'daily',
|
'frequency' => 'daily',
|
||||||
'time_of_day' => '01:00:00',
|
'time_of_day' => '01:00:00',
|
||||||
'days_of_week' => null,
|
'days_of_week' => null,
|
||||||
'policy_types' => ['deviceConfiguration'],
|
'policy_types' => [
|
||||||
|
'deviceConfiguration',
|
||||||
|
'groupPolicyConfiguration',
|
||||||
|
'settingsCatalogPolicy',
|
||||||
|
],
|
||||||
'include_foundations' => true,
|
'include_foundations' => true,
|
||||||
'retention_keep_last' => 30,
|
'retention_keep_last' => 30,
|
||||||
]);
|
]);
|
||||||
@ -45,9 +50,34 @@
|
|||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Tenant A schedule')
|
->assertSee('Tenant A schedule')
|
||||||
->assertSee('Device Configuration')
|
->assertSee('Device Configuration')
|
||||||
|
->assertSee('more')
|
||||||
->assertDontSee('Tenant B schedule');
|
->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 () {
|
test('backup schedules pages return 404 for unauthorized tenant', function () {
|
||||||
[$user] = createUserWithTenant(role: 'manager');
|
[$user] = createUserWithTenant(role: 'manager');
|
||||||
$unauthorizedTenant = Tenant::factory()->create();
|
$unauthorizedTenant = Tenant::factory()->create();
|
||||||
|
|||||||
@ -38,3 +38,44 @@
|
|||||||
|
|
||||||
Bus::assertDispatchedTimes(RunBackupScheduleJob::class, 1);
|
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\Jobs\RunBackupScheduleJob;
|
||||||
use App\Models\BackupSchedule;
|
use App\Models\BackupSchedule;
|
||||||
use App\Models\BackupScheduleRun;
|
use App\Models\BackupScheduleRun;
|
||||||
|
use App\Models\BulkOperationRun;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
@ -38,6 +40,15 @@
|
|||||||
|
|
||||||
$run = BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->first();
|
$run = BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->first();
|
||||||
expect($run)->not->toBeNull();
|
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);
|
Queue::assertPushed(RunBackupScheduleJob::class);
|
||||||
|
|
||||||
@ -45,6 +56,8 @@
|
|||||||
$this->assertDatabaseHas('notifications', [
|
$this->assertDatabaseHas('notifications', [
|
||||||
'notifiable_id' => $user->id,
|
'notifiable_id' => $user->id,
|
||||||
'notifiable_type' => User::class,
|
'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())
|
expect(BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count())
|
||||||
->toBe(1);
|
->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);
|
Queue::assertPushed(RunBackupScheduleJob::class);
|
||||||
$this->assertDatabaseCount('notifications', 1);
|
$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 () {
|
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())
|
expect(BackupScheduleRun::query()->whereIn('backup_schedule_id', [$scheduleA->id, $scheduleB->id])->count())
|
||||||
->toBe(2);
|
->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);
|
Queue::assertPushed(RunBackupScheduleJob::class, 2);
|
||||||
$this->assertDatabaseCount('notifications', 1);
|
$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 () {
|
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())
|
expect(BackupScheduleRun::query()->whereIn('backup_schedule_id', [$scheduleA->id, $scheduleB->id])->count())
|
||||||
->toBe(2);
|
->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);
|
Queue::assertPushed(RunBackupScheduleJob::class, 2);
|
||||||
$this->assertDatabaseCount('notifications', 1);
|
$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
|
<?php
|
||||||
|
|
||||||
use App\Livewire\BulkOperationProgress;
|
use App\Livewire\BulkOperationProgress;
|
||||||
|
use App\Models\BackupSchedule;
|
||||||
|
use App\Models\BackupScheduleRun;
|
||||||
use App\Models\BulkOperationRun;
|
use App\Models\BulkOperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@ -30,6 +32,7 @@
|
|||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
'status' => 'completed',
|
'status' => 'completed',
|
||||||
|
'updated_at' => now()->subMinutes(5),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Other user's op (should not show)
|
// Other user's op (should not show)
|
||||||
@ -47,3 +50,56 @@
|
|||||||
->assertSee('Delete Policy')
|
->assertSee('Delete Policy')
|
||||||
->assertSee('50 / 100');
|
->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