user(); if (! $user instanceof User) { return null; } return $user->tenantRole(Tenant::current()); } public static function canViewAny(): bool { return static::currentTenantRole() !== null; } public static function canView(Model $record): bool { return static::currentTenantRole() !== null; } public static function canCreate(): bool { return static::currentTenantRole()?->canManageBackupSchedules() ?? false; } public static function canEdit(Model $record): bool { return static::currentTenantRole()?->canManageBackupSchedules() ?? false; } public static function canDelete(Model $record): bool { return static::currentTenantRole()?->canManageBackupSchedules() ?? false; } public static function canDeleteAny(): bool { return static::currentTenantRole()?->canManageBackupSchedules() ?? false; } public static function form(Schema $schema): Schema { return $schema ->schema([ TextInput::make('name') ->label('Schedule Name') ->required() ->maxLength(255), Toggle::make('is_enabled') ->label('Enabled') ->default(true), Select::make('timezone') ->label('Timezone') ->options(static::timezoneOptions()) ->searchable() ->default('UTC') ->required(), Select::make('frequency') ->label('Frequency') ->options([ 'daily' => 'Daily', 'weekly' => 'Weekly', ]) ->default('daily') ->required() ->reactive(), TextInput::make('time_of_day') ->label('Time of day') ->type('time') ->required() ->extraInputAttributes(['step' => 60]), CheckboxList::make('days_of_week') ->label('Days of the week') ->options(static::dayOfWeekOptions()) ->columns(2) ->visible(fn (Get $get): bool => $get('frequency') === 'weekly') ->required(fn (Get $get): bool => $get('frequency') === 'weekly') ->rules(['array', 'min:1']), CheckboxList::make('policy_types') ->label('Policy types') ->options(static::policyTypeOptions()) ->columns(2) ->required() ->helperText('Select the Microsoft Graph policy types that should be included in each run.') ->rules([ 'array', 'min:1', new SupportedPolicyTypesRule, ]) ->columnSpanFull(), Toggle::make('include_foundations') ->label('Include foundation types') ->default(true), TextInput::make('retention_keep_last') ->label('Retention (keep last N Backup Sets)') ->type('number') ->default(30) ->minValue(1) ->required(), ]); } public static function table(Table $table): Table { return $table ->defaultSort('next_run_at', 'asc') ->columns([ IconColumn::make('is_enabled') ->label('Enabled') ->boolean() ->alignCenter(), TextColumn::make('name') ->searchable() ->label('Schedule'), TextColumn::make('frequency') ->label('Frequency') ->badge() ->formatStateUsing(fn (?string $state): string => match ($state) { 'daily' => 'Daily', 'weekly' => 'Weekly', default => (string) $state, }) ->color(fn (?string $state): string => match ($state) { 'daily' => 'success', 'weekly' => 'warning', default => 'gray', }), TextColumn::make('time_of_day') ->label('Time') ->formatStateUsing(fn (?string $state): ?string => $state ? trim($state) : null), TextColumn::make('timezone') ->label('Timezone'), TextColumn::make('policy_types') ->label('Policy types') ->wrap() ->getStateUsing(function (BackupSchedule $record): string { $state = $record->policy_types; if (is_string($state)) { $decoded = json_decode($state, true); if (is_array($decoded)) { $state = $decoded; } } if ($state instanceof \Illuminate\Contracts\Support\Arrayable) { $state = $state->toArray(); } if (! is_array($state)) { return 'None'; } $types = array_is_list($state) ? $state : array_keys(array_filter($state)); $types = array_values(array_filter($types, fn (mixed $type): bool => is_string($type) && $type !== '')); if ($types === []) { return 'None'; } $labelMap = collect(config('tenantpilot.supported_policy_types', [])) ->mapWithKeys(fn (array $policy): array => [ (string) ($policy['type'] ?? '') => (string) ($policy['label'] ?? Str::headline((string) ($policy['type'] ?? ''))), ]) ->filter(fn (string $label, string $type): bool => $type !== '') ->all(); $labels = array_map( fn (string $type): string => $labelMap[$type] ?? Str::headline($type), $types, ); return implode(', ', $labels); }), TextColumn::make('retention_keep_last') ->label('Retention') ->suffix(' sets'), TextColumn::make('last_run_status') ->label('Last run status') ->badge() ->formatStateUsing(fn (?string $state): string => match ($state) { BackupScheduleRun::STATUS_RUNNING => 'Running', BackupScheduleRun::STATUS_SUCCESS => 'Success', BackupScheduleRun::STATUS_PARTIAL => 'Partial', BackupScheduleRun::STATUS_FAILED => 'Failed', BackupScheduleRun::STATUS_CANCELED => 'Canceled', BackupScheduleRun::STATUS_SKIPPED => 'Skipped', default => $state ? Str::headline($state) : '—', }) ->color(fn (?string $state): string => match ($state) { BackupScheduleRun::STATUS_SUCCESS => 'success', BackupScheduleRun::STATUS_PARTIAL => 'warning', BackupScheduleRun::STATUS_RUNNING => 'primary', BackupScheduleRun::STATUS_SKIPPED => 'gray', BackupScheduleRun::STATUS_FAILED, BackupScheduleRun::STATUS_CANCELED => 'danger', default => 'gray', }), TextColumn::make('last_run_at') ->label('Last run') ->dateTime() ->sortable(), TextColumn::make('next_run_at') ->label('Next run') ->dateTime() ->sortable(), ]) ->filters([ SelectFilter::make('enabled_state') ->label('Enabled') ->options([ 'enabled' => 'Enabled', 'disabled' => 'Disabled', ]) ->query(function (Builder $query, array $data): void { $value = $data['value'] ?? null; if (blank($value)) { return; } if ($value === 'enabled') { $query->where('is_enabled', true); return; } if ($value === 'disabled') { $query->where('is_enabled', false); } }), ]) ->actions([ ActionGroup::make([ Action::make('runNow') ->label('Run now') ->icon('heroicon-o-play') ->color('success') ->visible(fn (): bool => static::currentTenantRole()?->canRunBackupSchedules() ?? false) ->action(function (BackupSchedule $record): void { abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403); $tenant = Tenant::current(); $user = auth()->user(); $scheduledFor = CarbonImmutable::now('UTC')->startOfMinute(); for ($i = 0; $i < 5; $i++) { $exists = BackupScheduleRun::query() ->where('backup_schedule_id', $record->id) ->where('scheduled_for', $scheduledFor) ->exists(); if (! $exists) { break; } $scheduledFor = $scheduledFor->addMinute(); } $run = BackupScheduleRun::create([ 'backup_schedule_id' => $record->id, 'tenant_id' => $tenant->getKey(), 'scheduled_for' => $scheduledFor->toDateTimeString(), 'status' => BackupScheduleRun::STATUS_RUNNING, 'summary' => null, ]); app(AuditLogger::class)->log( tenant: $tenant, action: 'backup_schedule.run_dispatched_manual', resourceType: 'backup_schedule_run', resourceId: (string) $run->id, status: 'success', context: [ 'metadata' => [ 'backup_schedule_id' => $record->id, 'backup_schedule_run_id' => $run->id, 'scheduled_for' => $scheduledFor->toDateTimeString(), 'trigger' => 'run_now', ], ], ); Bus::dispatch(new RunBackupScheduleJob($run->id)); if ($user instanceof User) { $user->notify(new BackupScheduleRunDispatchedNotification([ 'tenant_id' => (int) $tenant->getKey(), 'backup_schedule_id' => (int) $record->id, 'backup_schedule_run_id' => (int) $run->id, 'scheduled_for' => $scheduledFor->toDateTimeString(), 'trigger' => 'run_now', ])); } Notification::make() ->title('Run dispatched') ->body('The backup run has been queued.') ->success() ->send(); }), Action::make('retry') ->label('Retry') ->icon('heroicon-o-arrow-path') ->color('warning') ->visible(fn (): bool => static::currentTenantRole()?->canRunBackupSchedules() ?? false) ->action(function (BackupSchedule $record): void { abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403); $tenant = Tenant::current(); $user = auth()->user(); $scheduledFor = CarbonImmutable::now('UTC')->startOfMinute(); for ($i = 0; $i < 5; $i++) { $exists = BackupScheduleRun::query() ->where('backup_schedule_id', $record->id) ->where('scheduled_for', $scheduledFor) ->exists(); if (! $exists) { break; } $scheduledFor = $scheduledFor->addMinute(); } $run = BackupScheduleRun::create([ 'backup_schedule_id' => $record->id, 'tenant_id' => $tenant->getKey(), 'scheduled_for' => $scheduledFor->toDateTimeString(), 'status' => BackupScheduleRun::STATUS_RUNNING, 'summary' => null, ]); app(AuditLogger::class)->log( tenant: $tenant, action: 'backup_schedule.run_dispatched_manual', resourceType: 'backup_schedule_run', resourceId: (string) $run->id, status: 'success', context: [ 'metadata' => [ 'backup_schedule_id' => $record->id, 'backup_schedule_run_id' => $run->id, 'scheduled_for' => $scheduledFor->toDateTimeString(), 'trigger' => 'retry', ], ], ); Bus::dispatch(new RunBackupScheduleJob($run->id)); if ($user instanceof User) { $user->notify(new BackupScheduleRunDispatchedNotification([ 'tenant_id' => (int) $tenant->getKey(), 'backup_schedule_id' => (int) $record->id, 'backup_schedule_run_id' => (int) $run->id, 'scheduled_for' => $scheduledFor->toDateTimeString(), 'trigger' => 'retry', ])); } Notification::make() ->title('Retry dispatched') ->body('A new backup run has been queued.') ->success() ->send(); }), EditAction::make() ->visible(fn (): bool => static::currentTenantRole()?->canManageBackupSchedules() ?? false), DeleteAction::make() ->visible(fn (): bool => static::currentTenantRole()?->canManageBackupSchedules() ?? false), ])->icon('heroicon-o-ellipsis-vertical'), ]) ->bulkActions([ BulkActionGroup::make([ BulkAction::make('bulk_run_now') ->label('Run now') ->icon('heroicon-o-play') ->color('success') ->visible(fn (): bool => static::currentTenantRole()?->canRunBackupSchedules() ?? false) ->action(function (Collection $records): void { abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403); if ($records->isEmpty()) { return; } $tenant = Tenant::current(); $user = auth()->user(); $createdRunIds = []; /** @var BackupSchedule $record */ foreach ($records as $record) { $scheduledFor = CarbonImmutable::now('UTC')->startOfMinute(); for ($i = 0; $i < 5; $i++) { $exists = BackupScheduleRun::query() ->where('backup_schedule_id', $record->id) ->where('scheduled_for', $scheduledFor) ->exists(); if (! $exists) { break; } $scheduledFor = $scheduledFor->addMinute(); } $run = BackupScheduleRun::create([ 'backup_schedule_id' => $record->id, 'tenant_id' => $tenant->getKey(), 'scheduled_for' => $scheduledFor->toDateTimeString(), 'status' => BackupScheduleRun::STATUS_RUNNING, 'summary' => null, ]); $createdRunIds[] = (int) $run->id; app(AuditLogger::class)->log( tenant: $tenant, action: 'backup_schedule.run_dispatched_manual', resourceType: 'backup_schedule_run', resourceId: (string) $run->id, status: 'success', context: [ 'metadata' => [ 'backup_schedule_id' => $record->id, 'backup_schedule_run_id' => $run->id, 'scheduled_for' => $scheduledFor->toDateTimeString(), 'trigger' => 'bulk_run_now', ], ], ); Bus::dispatch(new RunBackupScheduleJob($run->id)); } if ($user instanceof User) { $user->notify(new BackupScheduleRunDispatchedNotification([ 'tenant_id' => (int) $tenant->getKey(), 'schedule_ids' => $records->pluck('id')->map(fn ($id) => (int) $id)->values()->all(), 'backup_schedule_run_ids' => $createdRunIds, 'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute()->toDateTimeString(), 'trigger' => 'bulk_run_now', ])); } Notification::make() ->title('Runs dispatched') ->body(sprintf('Queued %d run(s).', count($createdRunIds))) ->success() ->send(); }), BulkAction::make('bulk_retry') ->label('Retry') ->icon('heroicon-o-arrow-path') ->color('warning') ->visible(fn (): bool => static::currentTenantRole()?->canRunBackupSchedules() ?? false) ->action(function (Collection $records): void { abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403); if ($records->isEmpty()) { return; } $tenant = Tenant::current(); $user = auth()->user(); $createdRunIds = []; /** @var BackupSchedule $record */ foreach ($records as $record) { $scheduledFor = CarbonImmutable::now('UTC')->startOfMinute(); for ($i = 0; $i < 5; $i++) { $exists = BackupScheduleRun::query() ->where('backup_schedule_id', $record->id) ->where('scheduled_for', $scheduledFor) ->exists(); if (! $exists) { break; } $scheduledFor = $scheduledFor->addMinute(); } $run = BackupScheduleRun::create([ 'backup_schedule_id' => $record->id, 'tenant_id' => $tenant->getKey(), 'scheduled_for' => $scheduledFor->toDateTimeString(), 'status' => BackupScheduleRun::STATUS_RUNNING, 'summary' => null, ]); $createdRunIds[] = (int) $run->id; app(AuditLogger::class)->log( tenant: $tenant, action: 'backup_schedule.run_dispatched_manual', resourceType: 'backup_schedule_run', resourceId: (string) $run->id, status: 'success', context: [ 'metadata' => [ 'backup_schedule_id' => $record->id, 'backup_schedule_run_id' => $run->id, 'scheduled_for' => $scheduledFor->toDateTimeString(), 'trigger' => 'bulk_retry', ], ], ); Bus::dispatch(new RunBackupScheduleJob($run->id)); } if ($user instanceof User) { $user->notify(new BackupScheduleRunDispatchedNotification([ 'tenant_id' => (int) $tenant->getKey(), 'schedule_ids' => $records->pluck('id')->map(fn ($id) => (int) $id)->values()->all(), 'backup_schedule_run_ids' => $createdRunIds, 'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute()->toDateTimeString(), 'trigger' => 'bulk_retry', ])); } Notification::make() ->title('Retries dispatched') ->body(sprintf('Queued %d run(s).', count($createdRunIds))) ->success() ->send(); }), DeleteBulkAction::make('bulk_delete') ->visible(fn (): bool => static::currentTenantRole()?->canManageBackupSchedules() ?? false), ]), ]); } public static function getEloquentQuery(): Builder { $tenantId = Tenant::current()->getKey(); return parent::getEloquentQuery() ->where('tenant_id', $tenantId) ->orderByDesc('is_enabled') ->orderBy('next_run_at'); } public static function getRelations(): array { return [ BackupScheduleRunsRelationManager::class, ]; } public static function getPages(): array { return [ 'index' => Pages\ListBackupSchedules::route('/'), 'create' => Pages\CreateBackupSchedule::route('/create'), 'edit' => Pages\EditBackupSchedule::route('/{record}/edit'), ]; } public static function ensurePolicyTypes(array $data): array { $types = array_values((array) ($data['policy_types'] ?? [])); try { app(PolicyTypeResolver::class)->ensureSupported($types); } catch (InvalidPolicyTypeException $exception) { throw ValidationException::withMessages([ 'policy_types' => [sprintf('Unknown policy types: %s.', implode(', ', $exception->unknownPolicyTypes))], ]); } $data['policy_types'] = $types; return $data; } public static function assignTenant(array $data): array { $data['tenant_id'] = Tenant::current()->getKey(); return $data; } public static function hydrateNextRun(array $data): array { if (! empty($data['time_of_day'])) { $data['time_of_day'] = static::normalizeTimeOfDay($data['time_of_day']); } $schedule = new BackupSchedule; $schedule->forceFill([ 'frequency' => $data['frequency'] ?? 'daily', 'time_of_day' => $data['time_of_day'] ?? '00:00:00', 'timezone' => $data['timezone'] ?? 'UTC', 'days_of_week' => (array) ($data['days_of_week'] ?? []), ]); $nextRun = app(ScheduleTimeService::class)->nextRunFor($schedule); $data['next_run_at'] = $nextRun?->toDateTimeString(); return $data; } public static function normalizeTimeOfDay(string $time): string { if (preg_match('/^\d{2}:\d{2}$/', $time)) { return $time.':00'; } return $time; } protected static function timezoneOptions(): array { $zones = DateTimeZone::listIdentifiers(); sort($zones); return array_combine($zones, $zones); } protected static function policyTypeOptions(): array { return static::policyTypeLabelMap(); } protected static function policyTypeLabels(array $types): array { $map = static::policyTypeLabelMap(); return array_map(fn (string $type): string => $map[$type] ?? Str::headline($type), $types); } protected static function policyTypeLabelMap(): array { return collect(config('tenantpilot.supported_policy_types', [])) ->mapWithKeys(fn (array $policy) => [ $policy['type'] => $policy['label'] ?? Str::headline($policy['type']), ]) ->all(); } protected static function dayOfWeekOptions(): array { return [ 1 => 'Monday', 2 => 'Tuesday', 3 => 'Wednesday', 4 => 'Thursday', 5 => 'Friday', 6 => 'Saturday', 7 => 'Sunday', ]; } }