Kontext / Ziel Diese PR standardisiert Tenant‑RBAC Enforcement in der Filament‑UI: statt ad-hoc Gate::*, abort_if/abort_unless und kopierten ->visible()/->disabled()‑Closures gibt es jetzt eine zentrale, wiederverwendbare Implementierung für Actions (Header/Table/Bulk). Links zur Spec: spec.md plan.md quickstart.md Was ist drin Neue zentrale Helper-API: UiEnforcement (Tenant-plane RBAC‑UX “source of truth” für Filament Actions) Standardisierte Tooltip-Texte und Context-DTO (UiTooltips, TenantAccessContext) Migration vieler tenant‑scoped Filament Action-Surfaces auf das Standardpattern (ohne ad-hoc Auth-Patterns) CI‑Guard (Test) gegen neue ad-hoc Patterns in app/Filament/**: verbietet Gate::allows/denies/check/authorize, use Illuminate\Support\Facades\Gate, abort_if/abort_unless Legacy-Allowlist ist aktuell leer (neue Verstöße failen sofort) RBAC-UX Semantik (konsequent & testbar) Non-member: UI Actions hidden (kein Tenant‑Leak); Execution wird blockiert (Filament hidden→disabled chain), Defense‑in‑depth enthält zusätzlich serverseitige Guards. Member ohne Capability: Action visible aber disabled + Standard-Tooltip; Execution wird blockiert (keine Side Effects). Member mit Capability: Action enabled und ausführbar. Destructive actions: über ->destructive() immer mit ->requiresConfirmation() + klare Warntexte (Execution bleibt über ->action(...)). Wichtig: In Filament v5 sind hidden/disabled Actions typischerweise “silently blocked” (200, keine Ausführung). Die Tests prüfen daher UI‑State + “no side effects”, nicht nur HTTP‑Statuscodes. Sicherheit / Scope Keine neuen DB-Tabellen, keine Migrations, keine Microsoft Graph Calls (DB‑only bei Render; kein outbound HTTP). Tenant Isolation bleibt Isolation‑Boundary (deny-as-not-found auf Tenant‑Ebene, Capability erst nach Membership). Kein Asset-Setup erforderlich; keine neuen Filament Assets. Compliance Notes (Repo-Regeln) Filament v5 / Livewire v4.0+ kompatibel. Keine Änderungen an Provider‑Registrierung (Laravel 11+/12: providers.php bleibt der Ort; hier unverändert). Global Search: keine gezielte Änderung am Global‑Search-Verhalten in dieser PR. Tests / Qualität Pest Feature/Unit Tests für Member/Non-member/Tooltip/Destructive/Regression‑Guard. Guard-Test: “No ad-hoc Filament auth patterns”. Full suite laut Tasks: vendor/bin/sail artisan test --compact → 837 passed, 5 skipped. Checklist: requirements.md vollständig (16/16). Review-Fokus API‑Usage in neuen/angepassten Filament Actions: UiEnforcement::forAction/forTableAction/forBulkAction(...)->requireCapability(...)->apply() Guard-Test soll “red” werden, sobald jemand neue ad-hoc Auth‑Patterns einführt (by design). Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box> Reviewed-on: #81
1135 lines
49 KiB
PHP
1135 lines
49 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\Rules\SupportedPolicyTypesRule;
|
|
use App\Services\Auth\CapabilityResolver;
|
|
use App\Services\BackupScheduling\PolicyTypeResolver;
|
|
use App\Services\BackupScheduling\ScheduleTimeService;
|
|
use App\Services\Intune\AuditLogger;
|
|
use App\Services\OperationRunService;
|
|
use App\Support\Auth\Capabilities;
|
|
use App\Support\Badges\BadgeDomain;
|
|
use App\Support\Badges\BadgeRenderer;
|
|
use App\Support\Badges\TagBadgeDomain;
|
|
use App\Support\Badges\TagBadgeRenderer;
|
|
use App\Support\OperationRunLinks;
|
|
use App\Support\OpsUx\OperationUxPresenter;
|
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
|
use App\Support\Rbac\UiEnforcement;
|
|
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\TextColumn;
|
|
use Filament\Tables\Contracts\HasTable;
|
|
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\Database\UniqueConstraintViolationException;
|
|
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';
|
|
|
|
public static function canViewAny(): bool
|
|
{
|
|
$tenant = Tenant::current();
|
|
|
|
$user = auth()->user();
|
|
|
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
return false;
|
|
}
|
|
|
|
/** @var CapabilityResolver $resolver */
|
|
$resolver = app(CapabilityResolver::class);
|
|
|
|
return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
|
|
}
|
|
|
|
public static function canView(Model $record): bool
|
|
{
|
|
$tenant = Tenant::current();
|
|
|
|
$user = auth()->user();
|
|
|
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
return false;
|
|
}
|
|
|
|
/** @var CapabilityResolver $resolver */
|
|
$resolver = app(CapabilityResolver::class);
|
|
|
|
if (! $resolver->can($user, $tenant, Capabilities::TENANT_VIEW)) {
|
|
return false;
|
|
}
|
|
|
|
if ($record instanceof BackupSchedule) {
|
|
return (int) $record->tenant_id === (int) $tenant->getKey();
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
public static function canCreate(): bool
|
|
{
|
|
$tenant = Tenant::current();
|
|
|
|
$user = auth()->user();
|
|
|
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
return false;
|
|
}
|
|
|
|
/** @var CapabilityResolver $resolver */
|
|
$resolver = app(CapabilityResolver::class);
|
|
|
|
return $resolver->can($user, $tenant, Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE);
|
|
}
|
|
|
|
public static function canEdit(Model $record): bool
|
|
{
|
|
$tenant = Tenant::current();
|
|
|
|
$user = auth()->user();
|
|
|
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
return false;
|
|
}
|
|
|
|
/** @var CapabilityResolver $resolver */
|
|
$resolver = app(CapabilityResolver::class);
|
|
|
|
return $resolver->can($user, $tenant, Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE);
|
|
}
|
|
|
|
public static function canDelete(Model $record): bool
|
|
{
|
|
$tenant = Tenant::current();
|
|
|
|
$user = auth()->user();
|
|
|
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
return false;
|
|
}
|
|
|
|
/** @var CapabilityResolver $resolver */
|
|
$resolver = app(CapabilityResolver::class);
|
|
|
|
return $resolver->can($user, $tenant, Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE);
|
|
}
|
|
|
|
public static function canDeleteAny(): bool
|
|
{
|
|
$tenant = Tenant::current();
|
|
|
|
$user = auth()->user();
|
|
|
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
return false;
|
|
}
|
|
|
|
/** @var CapabilityResolver $resolver */
|
|
$resolver = app(CapabilityResolver::class);
|
|
|
|
return $resolver->can($user, $tenant, Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE);
|
|
}
|
|
|
|
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([
|
|
TextColumn::make('is_enabled')
|
|
->label('Enabled')
|
|
->badge()
|
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BooleanEnabled))
|
|
->color(BadgeRenderer::color(BadgeDomain::BooleanEnabled))
|
|
->icon(BadgeRenderer::icon(BadgeDomain::BooleanEnabled))
|
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BooleanEnabled))
|
|
->alignCenter(),
|
|
|
|
TextColumn::make('name')
|
|
->searchable()
|
|
->label('Schedule'),
|
|
|
|
TextColumn::make('frequency')
|
|
->label('Frequency')
|
|
->badge()
|
|
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::BackupScheduleFrequency))
|
|
->color(TagBadgeRenderer::color(TagBadgeDomain::BackupScheduleFrequency)),
|
|
|
|
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')
|
|
->getStateUsing(fn (BackupSchedule $record): string => static::policyTypesPreviewLabel($record))
|
|
->tooltip(fn (BackupSchedule $record): string => static::policyTypesFullLabel($record)),
|
|
|
|
TextColumn::make('retention_keep_last')
|
|
->label('Retention')
|
|
->suffix(' sets'),
|
|
|
|
TextColumn::make('last_run_status')
|
|
->label('Last run status')
|
|
->badge()
|
|
->formatStateUsing(function (?string $state): string {
|
|
if (! filled($state)) {
|
|
return '—';
|
|
}
|
|
|
|
return BadgeRenderer::spec(BadgeDomain::BackupScheduleRunStatus, $state)->label;
|
|
})
|
|
->color(function (?string $state): string {
|
|
if (! filled($state)) {
|
|
return 'gray';
|
|
}
|
|
|
|
return BadgeRenderer::spec(BadgeDomain::BackupScheduleRunStatus, $state)->color;
|
|
})
|
|
->icon(function (?string $state): ?string {
|
|
if (! filled($state)) {
|
|
return null;
|
|
}
|
|
|
|
return BadgeRenderer::spec(BadgeDomain::BackupScheduleRunStatus, $state)->icon;
|
|
})
|
|
->iconColor(function (?string $state): string {
|
|
if (! filled($state)) {
|
|
return 'gray';
|
|
}
|
|
|
|
$spec = BadgeRenderer::spec(BadgeDomain::BackupScheduleRunStatus, $state);
|
|
|
|
return $spec->iconColor ?? $spec->color;
|
|
}),
|
|
|
|
TextColumn::make('last_run_at')
|
|
->label('Last run')
|
|
->dateTime()
|
|
->sortable(),
|
|
|
|
TextColumn::make('next_run_at')
|
|
->label('Next run')
|
|
->getStateUsing(function (BackupSchedule $record): ?string {
|
|
$nextRun = $record->next_run_at;
|
|
|
|
if (! $nextRun) {
|
|
return null;
|
|
}
|
|
|
|
$timezone = $record->timezone ?: 'UTC';
|
|
|
|
try {
|
|
return $nextRun->setTimezone($timezone)->format('M j, Y H:i:s');
|
|
} catch (\Throwable) {
|
|
return $nextRun->format('M j, Y H:i:s');
|
|
}
|
|
})
|
|
->sortable(),
|
|
])
|
|
->filters([
|
|
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([
|
|
UiEnforcement::forAction(
|
|
Action::make('runNow')
|
|
->label('Run now')
|
|
->icon('heroicon-o-play')
|
|
->color('success')
|
|
->action(function (BackupSchedule $record, HasTable $livewire): void {
|
|
$tenant = Tenant::current();
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
Notification::make()
|
|
->title('No tenant selected')
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$user = auth()->user();
|
|
$userId = auth()->id();
|
|
$userModel = $user instanceof User ? $user : ($userId ? User::query()->find($userId) : null);
|
|
|
|
/** @var OperationRunService $operationRunService */
|
|
$operationRunService = app(OperationRunService::class);
|
|
$operationRun = $operationRunService->ensureRun(
|
|
tenant: $tenant,
|
|
type: 'backup_schedule.run_now',
|
|
inputs: [
|
|
'backup_schedule_id' => (int) $record->getKey(),
|
|
],
|
|
initiator: $userModel
|
|
);
|
|
|
|
if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) {
|
|
Notification::make()
|
|
->title('Run already queued')
|
|
->body('This schedule already has a queued or running backup.')
|
|
->warning()
|
|
->actions([
|
|
Action::make('view_run')
|
|
->label('View run')
|
|
->url(OperationRunLinks::view($operationRun, $tenant)),
|
|
])
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
|
|
$run = null;
|
|
|
|
for ($i = 0; $i < 5; $i++) {
|
|
try {
|
|
$run = BackupScheduleRun::create([
|
|
'backup_schedule_id' => $record->id,
|
|
'tenant_id' => $tenant->getKey(),
|
|
'user_id' => $userId,
|
|
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
|
'status' => BackupScheduleRun::STATUS_RUNNING,
|
|
'summary' => null,
|
|
]);
|
|
break;
|
|
} catch (UniqueConstraintViolationException) {
|
|
$scheduledFor = $scheduledFor->addMinute();
|
|
}
|
|
}
|
|
|
|
if (! $run instanceof BackupScheduleRun) {
|
|
Notification::make()
|
|
->title('Run already queued')
|
|
->body('Please wait a moment and try again.')
|
|
->warning()
|
|
->actions([
|
|
Action::make('view_run')
|
|
->label('View run')
|
|
->url(OperationRunLinks::view($operationRun, $tenant)),
|
|
])
|
|
->send();
|
|
|
|
$operationRunService->updateRun(
|
|
$operationRun,
|
|
status: 'completed',
|
|
outcome: 'failed',
|
|
summaryCounts: [
|
|
'backup_schedule_id' => (int) $record->getKey(),
|
|
],
|
|
failures: [
|
|
[
|
|
'code' => 'SCHEDULE_CONFLICT',
|
|
'message' => 'Unable to queue a unique backup schedule run.',
|
|
],
|
|
],
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
$operationRun->update([
|
|
'context' => array_merge($operationRun->context ?? [], [
|
|
'backup_schedule_id' => (int) $record->getKey(),
|
|
'backup_schedule_run_id' => (int) $run->getKey(),
|
|
]),
|
|
]);
|
|
|
|
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',
|
|
],
|
|
],
|
|
);
|
|
|
|
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void {
|
|
Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun));
|
|
});
|
|
|
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
|
OperationUxPresenter::queuedToast((string) $operationRun->type)
|
|
->actions([
|
|
Action::make('view_run')
|
|
->label('View run')
|
|
->url(OperationRunLinks::view($operationRun, $tenant)),
|
|
])
|
|
->send();
|
|
})
|
|
)
|
|
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN)
|
|
->apply(),
|
|
UiEnforcement::forAction(
|
|
Action::make('retry')
|
|
->label('Retry')
|
|
->icon('heroicon-o-arrow-path')
|
|
->color('warning')
|
|
->action(function (BackupSchedule $record, HasTable $livewire): void {
|
|
$tenant = Tenant::current();
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
Notification::make()
|
|
->title('No tenant selected')
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$user = auth()->user();
|
|
$userId = auth()->id();
|
|
$userModel = $user instanceof User ? $user : ($userId ? User::query()->find($userId) : null);
|
|
|
|
/** @var OperationRunService $operationRunService */
|
|
$operationRunService = app(OperationRunService::class);
|
|
$operationRun = $operationRunService->ensureRun(
|
|
tenant: $tenant,
|
|
type: 'backup_schedule.retry',
|
|
inputs: [
|
|
'backup_schedule_id' => (int) $record->getKey(),
|
|
],
|
|
initiator: $userModel
|
|
);
|
|
|
|
if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) {
|
|
Notification::make()
|
|
->title('Retry already queued')
|
|
->body('This schedule already has a queued or running retry.')
|
|
->warning()
|
|
->actions([
|
|
Action::make('view_run')
|
|
->label('View run')
|
|
->url(OperationRunLinks::view($operationRun, $tenant)),
|
|
])
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
|
|
$run = null;
|
|
|
|
for ($i = 0; $i < 5; $i++) {
|
|
try {
|
|
$run = BackupScheduleRun::create([
|
|
'backup_schedule_id' => $record->id,
|
|
'tenant_id' => $tenant->getKey(),
|
|
'user_id' => $userId,
|
|
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
|
'status' => BackupScheduleRun::STATUS_RUNNING,
|
|
'summary' => null,
|
|
]);
|
|
break;
|
|
} catch (UniqueConstraintViolationException) {
|
|
$scheduledFor = $scheduledFor->addMinute();
|
|
}
|
|
}
|
|
|
|
if (! $run instanceof BackupScheduleRun) {
|
|
Notification::make()
|
|
->title('Retry already queued')
|
|
->body('Please wait a moment and try again.')
|
|
->warning()
|
|
->actions([
|
|
Action::make('view_run')
|
|
->label('View run')
|
|
->url(OperationRunLinks::view($operationRun, $tenant)),
|
|
])
|
|
->send();
|
|
|
|
$operationRunService->updateRun(
|
|
$operationRun,
|
|
status: 'completed',
|
|
outcome: 'failed',
|
|
summaryCounts: [
|
|
'backup_schedule_id' => (int) $record->getKey(),
|
|
],
|
|
failures: [
|
|
[
|
|
'code' => 'SCHEDULE_CONFLICT',
|
|
'message' => 'Unable to queue a unique backup schedule retry run.',
|
|
],
|
|
],
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
$operationRun->update([
|
|
'context' => array_merge($operationRun->context ?? [], [
|
|
'backup_schedule_id' => (int) $record->getKey(),
|
|
'backup_schedule_run_id' => (int) $run->getKey(),
|
|
]),
|
|
]);
|
|
|
|
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',
|
|
],
|
|
],
|
|
);
|
|
|
|
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void {
|
|
Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun));
|
|
});
|
|
|
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
|
OperationUxPresenter::queuedToast((string) $operationRun->type)
|
|
->actions([
|
|
Action::make('view_run')
|
|
->label('View run')
|
|
->url(OperationRunLinks::view($operationRun, $tenant)),
|
|
])
|
|
->send();
|
|
})
|
|
)
|
|
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN)
|
|
->apply(),
|
|
UiEnforcement::forAction(
|
|
EditAction::make()
|
|
)
|
|
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE)
|
|
->apply(),
|
|
UiEnforcement::forAction(
|
|
DeleteAction::make()
|
|
)
|
|
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE)
|
|
->apply(),
|
|
])->icon('heroicon-o-ellipsis-vertical'),
|
|
])
|
|
->bulkActions([
|
|
BulkActionGroup::make([
|
|
UiEnforcement::forBulkAction(
|
|
BulkAction::make('bulk_run_now')
|
|
->label('Run now')
|
|
->icon('heroicon-o-play')
|
|
->color('success')
|
|
->action(function (Collection $records, HasTable $livewire): void {
|
|
$tenant = Tenant::current();
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
Notification::make()
|
|
->title('No tenant selected')
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
if ($records->isEmpty()) {
|
|
return;
|
|
}
|
|
|
|
$tenant = Tenant::current();
|
|
$userId = auth()->id();
|
|
$user = $userId ? User::query()->find($userId) : null;
|
|
/** @var OperationRunService $operationRunService */
|
|
$operationRunService = app(OperationRunService::class);
|
|
|
|
$bulkRun = null;
|
|
|
|
$createdRunIds = [];
|
|
|
|
/** @var BackupSchedule $record */
|
|
foreach ($records as $record) {
|
|
$operationRun = $operationRunService->ensureRun(
|
|
tenant: $tenant,
|
|
type: 'backup_schedule.run_now',
|
|
inputs: [
|
|
'backup_schedule_id' => (int) $record->getKey(),
|
|
],
|
|
initiator: $user
|
|
);
|
|
|
|
if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) {
|
|
continue;
|
|
}
|
|
|
|
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
|
|
$run = null;
|
|
|
|
for ($i = 0; $i < 5; $i++) {
|
|
try {
|
|
$run = BackupScheduleRun::create([
|
|
'backup_schedule_id' => $record->id,
|
|
'tenant_id' => $tenant->getKey(),
|
|
'user_id' => $userId,
|
|
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
|
'status' => BackupScheduleRun::STATUS_RUNNING,
|
|
'summary' => null,
|
|
]);
|
|
break;
|
|
} catch (UniqueConstraintViolationException) {
|
|
$scheduledFor = $scheduledFor->addMinute();
|
|
}
|
|
}
|
|
|
|
if (! $run instanceof BackupScheduleRun) {
|
|
$operationRunService->updateRun(
|
|
$operationRun,
|
|
status: 'completed',
|
|
outcome: 'failed',
|
|
summaryCounts: [
|
|
'backup_schedule_id' => (int) $record->getKey(),
|
|
],
|
|
failures: [
|
|
[
|
|
'code' => 'SCHEDULE_CONFLICT',
|
|
'message' => 'Unable to queue a unique backup schedule run.',
|
|
],
|
|
],
|
|
);
|
|
|
|
continue;
|
|
}
|
|
|
|
$createdRunIds[] = (int) $run->id;
|
|
|
|
$operationRun->update([
|
|
'context' => array_merge($operationRun->context ?? [], [
|
|
'backup_schedule_id' => (int) $record->getKey(),
|
|
'backup_schedule_run_id' => (int) $run->getKey(),
|
|
]),
|
|
]);
|
|
|
|
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',
|
|
],
|
|
],
|
|
);
|
|
|
|
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void {
|
|
Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun));
|
|
}, emitQueuedNotification: false);
|
|
}
|
|
|
|
$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) {
|
|
$notification->actions([
|
|
Action::make('view_runs')
|
|
->label('View in Operations')
|
|
->url(OperationRunLinks::index($tenant)),
|
|
])->sendToDatabase($user);
|
|
}
|
|
|
|
$notification->send();
|
|
|
|
if (count($createdRunIds) > 0) {
|
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
|
}
|
|
})
|
|
)
|
|
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN)
|
|
->apply(),
|
|
UiEnforcement::forBulkAction(
|
|
BulkAction::make('bulk_retry')
|
|
->label('Retry')
|
|
->icon('heroicon-o-arrow-path')
|
|
->color('warning')
|
|
->action(function (Collection $records, HasTable $livewire): void {
|
|
$tenant = Tenant::current();
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
Notification::make()
|
|
->title('No tenant selected')
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
if ($records->isEmpty()) {
|
|
return;
|
|
}
|
|
|
|
$tenant = Tenant::current();
|
|
$userId = auth()->id();
|
|
$user = $userId ? User::query()->find($userId) : null;
|
|
/** @var OperationRunService $operationRunService */
|
|
$operationRunService = app(OperationRunService::class);
|
|
|
|
$bulkRun = null;
|
|
|
|
$createdRunIds = [];
|
|
|
|
/** @var BackupSchedule $record */
|
|
foreach ($records as $record) {
|
|
$operationRun = $operationRunService->ensureRun(
|
|
tenant: $tenant,
|
|
type: 'backup_schedule.retry',
|
|
inputs: [
|
|
'backup_schedule_id' => (int) $record->getKey(),
|
|
],
|
|
initiator: $user
|
|
);
|
|
|
|
if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) {
|
|
continue;
|
|
}
|
|
|
|
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
|
|
$run = null;
|
|
|
|
for ($i = 0; $i < 5; $i++) {
|
|
try {
|
|
$run = BackupScheduleRun::create([
|
|
'backup_schedule_id' => $record->id,
|
|
'tenant_id' => $tenant->getKey(),
|
|
'user_id' => $userId,
|
|
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
|
'status' => BackupScheduleRun::STATUS_RUNNING,
|
|
'summary' => null,
|
|
]);
|
|
break;
|
|
} catch (UniqueConstraintViolationException) {
|
|
$scheduledFor = $scheduledFor->addMinute();
|
|
}
|
|
}
|
|
|
|
if (! $run instanceof BackupScheduleRun) {
|
|
$operationRunService->updateRun(
|
|
$operationRun,
|
|
status: 'completed',
|
|
outcome: 'failed',
|
|
summaryCounts: [
|
|
'backup_schedule_id' => (int) $record->getKey(),
|
|
],
|
|
failures: [
|
|
[
|
|
'code' => 'SCHEDULE_CONFLICT',
|
|
'message' => 'Unable to queue a unique backup schedule retry run.',
|
|
],
|
|
],
|
|
);
|
|
|
|
continue;
|
|
}
|
|
|
|
$createdRunIds[] = (int) $run->id;
|
|
|
|
$operationRun->update([
|
|
'context' => array_merge($operationRun->context ?? [], [
|
|
'backup_schedule_id' => (int) $record->getKey(),
|
|
'backup_schedule_run_id' => (int) $run->getKey(),
|
|
]),
|
|
]);
|
|
|
|
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',
|
|
],
|
|
],
|
|
);
|
|
|
|
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void {
|
|
Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun));
|
|
}, emitQueuedNotification: false);
|
|
}
|
|
|
|
$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) {
|
|
$notification->actions([
|
|
Action::make('view_runs')
|
|
->label('View in Operations')
|
|
->url(OperationRunLinks::index($tenant)),
|
|
])->sendToDatabase($user);
|
|
}
|
|
|
|
$notification->send();
|
|
|
|
if (count($createdRunIds) > 0) {
|
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
|
}
|
|
})
|
|
)
|
|
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN)
|
|
->apply(),
|
|
UiEnforcement::forBulkAction(
|
|
DeleteBulkAction::make('bulk_delete')
|
|
)
|
|
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE)
|
|
->apply(),
|
|
]),
|
|
]);
|
|
}
|
|
|
|
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 policyTypesFullLabel(BackupSchedule $record): string
|
|
{
|
|
$labels = static::policyTypesLabels($record);
|
|
|
|
return $labels === [] ? 'None' : implode(', ', $labels);
|
|
}
|
|
|
|
public static function policyTypesPreviewLabel(BackupSchedule $record): string
|
|
{
|
|
$labels = static::policyTypesLabels($record);
|
|
|
|
if ($labels === []) {
|
|
return 'None';
|
|
}
|
|
|
|
$preview = array_slice($labels, 0, 2);
|
|
$remaining = count($labels) - count($preview);
|
|
|
|
$label = implode(', ', $preview);
|
|
|
|
if ($remaining > 0) {
|
|
$label .= sprintf(' +%d more', $remaining);
|
|
}
|
|
|
|
return $label;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
private static function policyTypesLabels(BackupSchedule $record): array
|
|
{
|
|
$state = $record->policy_types;
|
|
|
|
if (is_string($state)) {
|
|
$decoded = json_decode($state, true);
|
|
|
|
if (is_array($decoded)) {
|
|
$state = $decoded;
|
|
}
|
|
}
|
|
|
|
if ($state instanceof \Illuminate\Contracts\Support\Arrayable) {
|
|
$state = $state->toArray();
|
|
}
|
|
|
|
if (blank($state) || (! is_array($state))) {
|
|
return [];
|
|
}
|
|
|
|
$types = array_is_list($state)
|
|
? $state
|
|
: array_keys(array_filter($state));
|
|
|
|
$types = array_values(array_filter($types, fn (mixed $type): bool => is_string($type) && $type !== ''));
|
|
|
|
if ($types === []) {
|
|
return [];
|
|
}
|
|
|
|
$labelMap = collect(config('tenantpilot.supported_policy_types', []))
|
|
->mapWithKeys(fn (array $policy): array => [
|
|
(string) ($policy['type'] ?? '') => (string) ($policy['label'] ?? Str::headline((string) ($policy['type'] ?? ''))),
|
|
])
|
|
->filter(fn (string $label, string $type): bool => $type !== '')
|
|
->all();
|
|
|
|
return array_map(
|
|
fn (string $type): string => $labelMap[$type] ?? Str::headline($type),
|
|
$types,
|
|
);
|
|
}
|
|
|
|
public static function ensurePolicyTypes(array $data): array
|
|
{
|
|
$types = array_values((array) ($data['policy_types'] ?? []));
|
|
|
|
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',
|
|
];
|
|
}
|
|
}
|