Compare commits
No commits in common. "751b066edd41156c4de012b64d8a6f25abee10ba" and "c00b2d32ee5608350e1399a1e96375f67e266d5c" have entirely different histories.
751b066edd
...
c00b2d32ee
@ -35,13 +35,6 @@ ## 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.
|
||||||
|
|||||||
@ -1,164 +0,0 @@
|
|||||||
<?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,7 +30,6 @@
|
|||||||
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;
|
||||||
@ -42,7 +41,6 @@
|
|||||||
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;
|
||||||
@ -202,8 +200,50 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
TextColumn::make('policy_types')
|
TextColumn::make('policy_types')
|
||||||
->label('Policy types')
|
->label('Policy types')
|
||||||
->getStateUsing(fn (BackupSchedule $record): string => static::policyTypesPreviewLabel($record))
|
->wrap()
|
||||||
->tooltip(fn (BackupSchedule $record): string => static::policyTypesFullLabel($record)),
|
->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);
|
||||||
|
}),
|
||||||
|
|
||||||
TextColumn::make('retention_keep_last')
|
TextColumn::make('retention_keep_last')
|
||||||
->label('Retention')
|
->label('Retention')
|
||||||
@ -238,21 +278,7 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
TextColumn::make('next_run_at')
|
TextColumn::make('next_run_at')
|
||||||
->label('Next run')
|
->label('Next run')
|
||||||
->getStateUsing(function (BackupSchedule $record): ?string {
|
->dateTime()
|
||||||
$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([
|
||||||
@ -292,37 +318,28 @@ 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++) {
|
||||||
try {
|
$exists = BackupScheduleRun::query()
|
||||||
|
->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,
|
||||||
@ -340,34 +357,23 @@ public static function table(Table $table): Table
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
$bulkRunId = null;
|
Bus::dispatch(new RunBackupScheduleJob($run->id));
|
||||||
|
|
||||||
if ($userModel instanceof User) {
|
if ($user instanceof User) {
|
||||||
$bulkRunId = app(BulkOperationService::class)
|
$user->notify(new BackupScheduleRunDispatchedNotification([
|
||||||
->createRun(
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
tenant: $tenant,
|
'backup_schedule_id' => (int) $record->id,
|
||||||
user: $userModel,
|
'backup_schedule_run_id' => (int) $run->id,
|
||||||
resource: 'backup_schedule',
|
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||||
action: 'run',
|
'trigger' => 'run_now',
|
||||||
itemIds: [(string) $record->id],
|
]));
|
||||||
totalItems: 1,
|
|
||||||
)
|
|
||||||
->id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRunId));
|
Notification::make()
|
||||||
|
|
||||||
$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')
|
||||||
@ -379,37 +385,28 @@ 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++) {
|
||||||
try {
|
$exists = BackupScheduleRun::query()
|
||||||
|
->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,
|
||||||
@ -427,34 +424,23 @@ public static function table(Table $table): Table
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
$bulkRunId = null;
|
Bus::dispatch(new RunBackupScheduleJob($run->id));
|
||||||
|
|
||||||
if ($userModel instanceof User) {
|
if ($user instanceof User) {
|
||||||
$bulkRunId = app(BulkOperationService::class)
|
$user->notify(new BackupScheduleRunDispatchedNotification([
|
||||||
->createRun(
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
tenant: $tenant,
|
'backup_schedule_id' => (int) $record->id,
|
||||||
user: $userModel,
|
'backup_schedule_run_id' => (int) $run->id,
|
||||||
resource: 'backup_schedule',
|
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||||
action: 'retry',
|
'trigger' => 'retry',
|
||||||
itemIds: [(string) $record->id],
|
]));
|
||||||
totalItems: 1,
|
|
||||||
)
|
|
||||||
->id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRunId));
|
Notification::make()
|
||||||
|
|
||||||
$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),
|
||||||
@ -477,47 +463,33 @@ public static function table(Table $table): Table
|
|||||||
}
|
}
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
$userId = auth()->id();
|
$user = auth()->user();
|
||||||
$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++) {
|
||||||
try {
|
$exists = BackupScheduleRun::query()
|
||||||
|
->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;
|
||||||
|
|
||||||
@ -533,30 +505,28 @@ 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, $bulkRun?->id));
|
Bus::dispatch(new RunBackupScheduleJob($run->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->notifyNow($notification->toDatabase());
|
$user->notify(new BackupScheduleRunDispatchedNotification([
|
||||||
DatabaseNotificationsSent::dispatch($user);
|
'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',
|
||||||
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
$notification->send();
|
Notification::make()
|
||||||
|
->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')
|
||||||
@ -571,47 +541,33 @@ public static function table(Table $table): Table
|
|||||||
}
|
}
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
$userId = auth()->id();
|
$user = auth()->user();
|
||||||
$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++) {
|
||||||
try {
|
$exists = BackupScheduleRun::query()
|
||||||
|
->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;
|
||||||
|
|
||||||
@ -627,30 +583,28 @@ 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, $bulkRun?->id));
|
Bus::dispatch(new RunBackupScheduleJob($run->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->notifyNow($notification->toDatabase());
|
$user->notify(new BackupScheduleRunDispatchedNotification([
|
||||||
DatabaseNotificationsSent::dispatch($user);
|
'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',
|
||||||
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
$notification->send();
|
Notification::make()
|
||||||
|
->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),
|
||||||
@ -684,79 +638,6 @@ 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,17 +4,13 @@
|
|||||||
|
|
||||||
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;
|
||||||
@ -30,10 +26,7 @@ class RunBackupScheduleJob implements ShouldQueue
|
|||||||
|
|
||||||
public int $tries = 3;
|
public int $tries = 3;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(public int $backupScheduleRunId) {}
|
||||||
public int $backupScheduleRunId,
|
|
||||||
public ?int $bulkRunId = null,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function handle(
|
public function handle(
|
||||||
PolicySyncService $policySyncService,
|
PolicySyncService $policySyncService,
|
||||||
@ -42,31 +35,15 @@ 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', 'user'])
|
->with(['schedule', 'tenant'])
|
||||||
->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) {
|
||||||
@ -104,7 +81,6 @@ 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;
|
||||||
@ -118,8 +94,6 @@ 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',
|
||||||
@ -150,7 +124,6 @@ public function handle(
|
|||||||
'unknown_policy_types' => $unknownTypes,
|
'unknown_policy_types' => $unknownTypes,
|
||||||
],
|
],
|
||||||
scheduleTimeService: $scheduleTimeService,
|
scheduleTimeService: $scheduleTimeService,
|
||||||
bulkRunId: $this->bulkRunId,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@ -209,7 +182,6 @@ 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(
|
||||||
@ -248,7 +220,6 @@ public function handle(
|
|||||||
'attempt' => $attempt,
|
'attempt' => $attempt,
|
||||||
],
|
],
|
||||||
scheduleTimeService: $scheduleTimeService,
|
scheduleTimeService: $scheduleTimeService,
|
||||||
bulkRunId: $this->bulkRunId,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$auditLogger->log(
|
$auditLogger->log(
|
||||||
@ -270,56 +241,6 @@ 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,
|
||||||
@ -329,7 +250,6 @@ 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');
|
||||||
|
|
||||||
@ -348,50 +268,6 @@ 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,10 +2,8 @@
|
|||||||
|
|
||||||
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;
|
||||||
|
|
||||||
@ -15,12 +13,9 @@ 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -40,102 +35,12 @@ 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())
|
||||||
->where(function ($query) use ($recentThreshold): void {
|
->whereIn('status', ['pending', 'running'])
|
||||||
$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,11 +41,6 @@ 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,9 +8,8 @@
|
|||||||
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\UniqueConstraintViolationException;
|
use Illuminate\Database\QueryException;
|
||||||
use Illuminate\Support\Facades\Bus;
|
use Illuminate\Support\Facades\Bus;
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
|
|
||||||
class BackupScheduleDispatcher
|
class BackupScheduleDispatcher
|
||||||
{
|
{
|
||||||
@ -72,19 +71,10 @@ public function dispatchDue(?array $tenantIdentifiers = null): array
|
|||||||
'status' => BackupScheduleRun::STATUS_RUNNING,
|
'status' => BackupScheduleRun::STATUS_RUNNING,
|
||||||
'summary' => null,
|
'summary' => null,
|
||||||
]);
|
]);
|
||||||
} catch (UniqueConstraintViolationException) {
|
} catch (QueryException $exception) {
|
||||||
// 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,24 +109,8 @@ 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
|
||||||
|
|||||||
@ -1,35 +0,0 @@
|
|||||||
<?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,13 +13,12 @@
|
|||||||
</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>
|
||||||
{{ $isStalePending ? 'Queued…' : 'Starting...' }}
|
Starting...
|
||||||
</span>
|
</span>
|
||||||
@elseif($run->status === 'running')
|
@elseif($run->status === 'running')
|
||||||
<span class="inline-flex items-center">
|
<span class="inline-flex items-center">
|
||||||
@ -29,10 +28,6 @@
|
|||||||
</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,8 +88,6 @@ ### 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,7 +4,6 @@
|
|||||||
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;
|
||||||
|
|
||||||
@ -22,11 +21,7 @@
|
|||||||
'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' => [
|
'policy_types' => ['deviceConfiguration'],
|
||||||
'deviceConfiguration',
|
|
||||||
'groupPolicyConfiguration',
|
|
||||||
'settingsCatalogPolicy',
|
|
||||||
],
|
|
||||||
'include_foundations' => true,
|
'include_foundations' => true,
|
||||||
'retention_keep_last' => 30,
|
'retention_keep_last' => 30,
|
||||||
]);
|
]);
|
||||||
@ -50,34 +45,9 @@
|
|||||||
->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,44 +38,3 @@
|
|||||||
|
|
||||||
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,9 +4,7 @@
|
|||||||
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;
|
||||||
@ -40,15 +38,6 @@
|
|||||||
|
|
||||||
$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);
|
||||||
|
|
||||||
@ -56,8 +45,6 @@
|
|||||||
$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',
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -88,25 +75,8 @@
|
|||||||
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 () {
|
||||||
@ -186,24 +156,8 @@
|
|||||||
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 () {
|
||||||
@ -246,86 +200,6 @@
|
|||||||
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,8 +1,6 @@
|
|||||||
<?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;
|
||||||
@ -32,7 +30,6 @@
|
|||||||
'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)
|
||||||
@ -50,56 +47,3 @@
|
|||||||
->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');
|
|
||||||
});
|
|
||||||
|
|||||||
@ -1,143 +0,0 @@
|
|||||||
<?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