TenantAtlas/app/Filament/Resources/BackupScheduleResource.php
2026-01-05 05:15:47 +01:00

738 lines
30 KiB
PHP

<?php
namespace App\Filament\Resources;
use App\Exceptions\InvalidPolicyTypeException;
use App\Filament\Resources\BackupScheduleResource\Pages;
use App\Filament\Resources\BackupScheduleResource\RelationManagers\BackupScheduleRunsRelationManager;
use App\Jobs\RunBackupScheduleJob;
use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\Tenant;
use App\Models\User;
use App\Notifications\BackupScheduleRunDispatchedNotification;
use App\Rules\SupportedPolicyTypesRule;
use App\Services\BackupScheduling\PolicyTypeResolver;
use App\Services\BackupScheduling\ScheduleTimeService;
use App\Services\Intune\AuditLogger;
use App\Support\TenantRole;
use BackedEnum;
use Carbon\CarbonImmutable;
use DateTimeZone;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use UnitEnum;
class BackupScheduleResource extends Resource
{
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clock';
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
protected static function currentTenantRole(): ?TenantRole
{
$user = auth()->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',
];
}
}