feat/012-windows-update-rings #18
@ -26,12 +26,14 @@
|
|||||||
use Filament\Schemas\Components\Section;
|
use Filament\Schemas\Components\Section;
|
||||||
use Filament\Schemas\Components\Utilities\Get;
|
use Filament\Schemas\Components\Utilities\Get;
|
||||||
use Filament\Schemas\Components\Utilities\Set;
|
use Filament\Schemas\Components\Utilities\Set;
|
||||||
|
use Filament\Schemas\Components\Wizard\Step;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Contracts\HasTable;
|
use Filament\Tables\Contracts\HasTable;
|
||||||
use Filament\Tables\Filters\TrashedFilter;
|
use Filament\Tables\Filters\TrashedFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
@ -69,8 +71,10 @@ public static function form(Schema $schema): Schema
|
|||||||
})
|
})
|
||||||
->reactive()
|
->reactive()
|
||||||
->afterStateUpdated(function (Set $set): void {
|
->afterStateUpdated(function (Set $set): void {
|
||||||
$set('backup_item_ids', []);
|
$set('scope_mode', 'all');
|
||||||
|
$set('backup_item_ids', null);
|
||||||
$set('group_mapping', []);
|
$set('group_mapping', []);
|
||||||
|
$set('is_dry_run', true);
|
||||||
})
|
})
|
||||||
->required(),
|
->required(),
|
||||||
Forms\Components\CheckboxList::make('backup_item_ids')
|
Forms\Components\CheckboxList::make('backup_item_ids')
|
||||||
@ -137,6 +141,164 @@ public static function form(Schema $schema): Schema
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, Step>
|
||||||
|
*/
|
||||||
|
public static function getWizardSteps(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Step::make('Select Backup Set')
|
||||||
|
->description('What are we restoring from?')
|
||||||
|
->schema([
|
||||||
|
Forms\Components\Select::make('backup_set_id')
|
||||||
|
->label('Backup set')
|
||||||
|
->options(function () {
|
||||||
|
$tenantId = Tenant::current()->getKey();
|
||||||
|
|
||||||
|
return BackupSet::query()
|
||||||
|
->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId))
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->get()
|
||||||
|
->mapWithKeys(function (BackupSet $set) {
|
||||||
|
$label = sprintf(
|
||||||
|
'%s • %s items • %s',
|
||||||
|
$set->name,
|
||||||
|
$set->item_count ?? 0,
|
||||||
|
optional($set->created_at)->format('Y-m-d H:i')
|
||||||
|
);
|
||||||
|
|
||||||
|
return [$set->id => $label];
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->reactive()
|
||||||
|
->afterStateUpdated(function (Set $set): void {
|
||||||
|
$set('scope_mode', 'all');
|
||||||
|
$set('backup_item_ids', null);
|
||||||
|
$set('group_mapping', []);
|
||||||
|
$set('is_dry_run', true);
|
||||||
|
})
|
||||||
|
->required(),
|
||||||
|
]),
|
||||||
|
Step::make('Define Restore Scope')
|
||||||
|
->description('What exactly should be restored?')
|
||||||
|
->schema([
|
||||||
|
Forms\Components\Radio::make('scope_mode')
|
||||||
|
->label('Scope')
|
||||||
|
->options([
|
||||||
|
'all' => 'All items (default)',
|
||||||
|
'selected' => 'Selected items only',
|
||||||
|
])
|
||||||
|
->default('all')
|
||||||
|
->reactive()
|
||||||
|
->afterStateUpdated(function (Set $set, $state): void {
|
||||||
|
$set('group_mapping', []);
|
||||||
|
|
||||||
|
if ($state === 'all') {
|
||||||
|
$set('backup_item_ids', null);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->required(),
|
||||||
|
Forms\Components\CheckboxList::make('backup_item_ids')
|
||||||
|
->label('Items to restore')
|
||||||
|
->options(fn (Get $get) => static::restoreItemOptionData($get('backup_set_id'))['options'])
|
||||||
|
->descriptions(fn (Get $get) => static::restoreItemOptionData($get('backup_set_id'))['descriptions'])
|
||||||
|
->columns(1)
|
||||||
|
->searchable()
|
||||||
|
->bulkToggleable()
|
||||||
|
->reactive()
|
||||||
|
->afterStateUpdated(fn (Set $set) => $set('group_mapping', []))
|
||||||
|
->visible(fn (Get $get): bool => $get('scope_mode') === 'selected')
|
||||||
|
->required(fn (Get $get): bool => $get('scope_mode') === 'selected')
|
||||||
|
->helperText('Search by name, type, or ID. Include foundations (scope tags, assignment filters) with policies to re-map IDs.'),
|
||||||
|
Section::make('Group mapping')
|
||||||
|
->description('Some source groups do not exist in the target tenant. Map them or choose Skip.')
|
||||||
|
->schema(function (Get $get): array {
|
||||||
|
$backupSetId = $get('backup_set_id');
|
||||||
|
$scopeMode = $get('scope_mode') ?? 'all';
|
||||||
|
$selectedItemIds = $get('backup_item_ids');
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant || ! $backupSetId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
|
||||||
|
|
||||||
|
if ($scopeMode === 'selected' && ($selectedItemIds === null || $selectedItemIds === [])) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$unresolved = static::unresolvedGroups(
|
||||||
|
backupSetId: $backupSetId,
|
||||||
|
selectedItemIds: $scopeMode === 'selected' ? $selectedItemIds : null,
|
||||||
|
tenant: $tenant
|
||||||
|
);
|
||||||
|
|
||||||
|
return array_map(function (array $group) use ($tenant): Forms\Components\Select {
|
||||||
|
$groupId = $group['id'];
|
||||||
|
$label = $group['label'];
|
||||||
|
|
||||||
|
return Forms\Components\Select::make("group_mapping.{$groupId}")
|
||||||
|
->label($label)
|
||||||
|
->options([
|
||||||
|
'SKIP' => 'Skip assignment',
|
||||||
|
])
|
||||||
|
->searchable()
|
||||||
|
->getSearchResultsUsing(fn (string $search) => static::targetGroupOptions($tenant, $search))
|
||||||
|
->getOptionLabelUsing(fn (?string $value) => static::resolveTargetGroupLabel($tenant, $value))
|
||||||
|
->helperText('Choose a target group or select Skip.');
|
||||||
|
}, $unresolved);
|
||||||
|
})
|
||||||
|
->visible(function (Get $get): bool {
|
||||||
|
$backupSetId = $get('backup_set_id');
|
||||||
|
$scopeMode = $get('scope_mode') ?? 'all';
|
||||||
|
$selectedItemIds = $get('backup_item_ids');
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant || ! $backupSetId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
|
||||||
|
|
||||||
|
if ($scopeMode === 'selected' && ($selectedItemIds === null || $selectedItemIds === [])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return static::unresolvedGroups(
|
||||||
|
backupSetId: $backupSetId,
|
||||||
|
selectedItemIds: $scopeMode === 'selected' ? $selectedItemIds : null,
|
||||||
|
tenant: $tenant
|
||||||
|
) !== [];
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
Step::make('Safety & Conflict Checks')
|
||||||
|
->description('Defensive checks (Phase 4)')
|
||||||
|
->schema([
|
||||||
|
Forms\Components\Placeholder::make('safety_checks_placeholder')
|
||||||
|
->label('Status')
|
||||||
|
->content('Safety & conflict checks will be added in Phase 4.'),
|
||||||
|
]),
|
||||||
|
Step::make('Preview')
|
||||||
|
->description('Dry-run preview (Phase 5)')
|
||||||
|
->schema([
|
||||||
|
Forms\Components\Toggle::make('is_dry_run')
|
||||||
|
->label('Preview only (dry-run)')
|
||||||
|
->default(true),
|
||||||
|
Forms\Components\Placeholder::make('preview_placeholder')
|
||||||
|
->label('Preview')
|
||||||
|
->content('Preview diff summary will be added in Phase 5.'),
|
||||||
|
]),
|
||||||
|
Step::make('Confirm & Execute')
|
||||||
|
->description('Explicit confirmations (Phase 6)')
|
||||||
|
->schema([
|
||||||
|
Forms\Components\Placeholder::make('confirm_placeholder')
|
||||||
|
->label('Execution')
|
||||||
|
->content('Execution confirmations and gating will be added in Phase 6.'),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public static function table(Table $table): Table
|
public static function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
@ -533,13 +695,9 @@ private static function restoreItemOptionData(?int $backupSetId): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
static $cache = [];
|
$cacheKey = sprintf('restore_run_item_options:%s:%s', $tenant->getKey(), $backupSetId);
|
||||||
$cacheKey = $tenant->getKey().':'.$backupSetId;
|
|
||||||
|
|
||||||
if (isset($cache[$cacheKey])) {
|
|
||||||
return $cache[$cacheKey];
|
|
||||||
}
|
|
||||||
|
|
||||||
|
return Cache::store('array')->rememberForever($cacheKey, function () use ($backupSetId, $tenant): array {
|
||||||
$items = BackupItem::query()
|
$items = BackupItem::query()
|
||||||
->where('backup_set_id', $backupSetId)
|
->where('backup_set_id', $backupSetId)
|
||||||
->whereHas('backupSet', fn ($query) => $query->where('tenant_id', $tenant->getKey()))
|
->whereHas('backupSet', fn ($query) => $query->where('tenant_id', $tenant->getKey()))
|
||||||
@ -587,10 +745,11 @@ private static function restoreItemOptionData(?int $backupSetId): array
|
|||||||
$descriptions[$item->id] = implode(' • ', $parts);
|
$descriptions[$item->id] = implode(' • ', $parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $cache[$cacheKey] = [
|
return [
|
||||||
'options' => $options,
|
'options' => $options,
|
||||||
'descriptions' => $descriptions,
|
'descriptions' => $descriptions,
|
||||||
];
|
];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function createRestoreRun(array $data): RestoreRun
|
public static function createRestoreRun(array $data): RestoreRun
|
||||||
@ -608,10 +767,15 @@ public static function createRestoreRun(array $data): RestoreRun
|
|||||||
/** @var RestoreService $service */
|
/** @var RestoreService $service */
|
||||||
$service = app(RestoreService::class);
|
$service = app(RestoreService::class);
|
||||||
|
|
||||||
|
$scopeMode = $data['scope_mode'] ?? 'all';
|
||||||
|
$selectedItemIds = ($scopeMode === 'selected')
|
||||||
|
? ($data['backup_item_ids'] ?? null)
|
||||||
|
: null;
|
||||||
|
|
||||||
return $service->execute(
|
return $service->execute(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
backupSet: $backupSet,
|
backupSet: $backupSet,
|
||||||
selectedItemIds: $data['backup_item_ids'] ?? null,
|
selectedItemIds: is_array($selectedItemIds) ? $selectedItemIds : null,
|
||||||
dryRun: (bool) ($data['is_dry_run'] ?? true),
|
dryRun: (bool) ($data['is_dry_run'] ?? true),
|
||||||
actorEmail: auth()->user()?->email,
|
actorEmail: auth()->user()?->email,
|
||||||
actorName: auth()->user()?->name,
|
actorName: auth()->user()?->name,
|
||||||
|
|||||||
@ -3,13 +3,21 @@
|
|||||||
namespace App\Filament\Resources\RestoreRunResource\Pages;
|
namespace App\Filament\Resources\RestoreRunResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\RestoreRunResource;
|
use App\Filament\Resources\RestoreRunResource;
|
||||||
|
use Filament\Resources\Pages\Concerns\HasWizard;
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
class CreateRestoreRun extends CreateRecord
|
class CreateRestoreRun extends CreateRecord
|
||||||
{
|
{
|
||||||
|
use HasWizard;
|
||||||
|
|
||||||
protected static string $resource = RestoreRunResource::class;
|
protected static string $resource = RestoreRunResource::class;
|
||||||
|
|
||||||
|
public function getSteps(): array
|
||||||
|
{
|
||||||
|
return RestoreRunResource::getWizardSteps();
|
||||||
|
}
|
||||||
|
|
||||||
protected function handleRecordCreation(array $data): Model
|
protected function handleRecordCreation(array $data): Model
|
||||||
{
|
{
|
||||||
return RestoreRunResource::createRestoreRun($data);
|
return RestoreRunResource::createRestoreRun($data);
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Support\RestoreRunStatus;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
@ -35,17 +37,30 @@ public function backupSet(): BelongsTo
|
|||||||
return $this->belongsTo(BackupSet::class)->withTrashed();
|
return $this->belongsTo(BackupSet::class)->withTrashed();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeDeletable($query)
|
public function scopeDeletable(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->whereIn('status', ['completed', 'failed', 'aborted', 'completed_with_errors', 'partial', 'previewed']);
|
return $query->whereIn('status', array_map(
|
||||||
|
static fn (RestoreRunStatus $status): string => $status->value,
|
||||||
|
[
|
||||||
|
RestoreRunStatus::Draft,
|
||||||
|
RestoreRunStatus::Scoped,
|
||||||
|
RestoreRunStatus::Checked,
|
||||||
|
RestoreRunStatus::Previewed,
|
||||||
|
RestoreRunStatus::Completed,
|
||||||
|
RestoreRunStatus::Partial,
|
||||||
|
RestoreRunStatus::Failed,
|
||||||
|
RestoreRunStatus::Cancelled,
|
||||||
|
RestoreRunStatus::Aborted,
|
||||||
|
RestoreRunStatus::CompletedWithErrors,
|
||||||
|
]
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isDeletable(): bool
|
public function isDeletable(): bool
|
||||||
{
|
{
|
||||||
$status = strtolower(trim((string) $this->status));
|
$status = RestoreRunStatus::fromString($this->status);
|
||||||
$status = str_replace([' ', '-'], '_', $status);
|
|
||||||
|
|
||||||
return in_array($status, ['completed', 'failed', 'aborted', 'completed_with_errors', 'partial', 'previewed'], true);
|
return $status?->isDeletable() ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group mapping helpers
|
// Group mapping helpers
|
||||||
|
|||||||
@ -40,6 +40,10 @@ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedIt
|
|||||||
{
|
{
|
||||||
$this->assertActiveContext($tenant, $backupSet);
|
$this->assertActiveContext($tenant, $backupSet);
|
||||||
|
|
||||||
|
if ($selectedItemIds === []) {
|
||||||
|
$selectedItemIds = null;
|
||||||
|
}
|
||||||
|
|
||||||
$items = $this->loadItems($backupSet, $selectedItemIds);
|
$items = $this->loadItems($backupSet, $selectedItemIds);
|
||||||
|
|
||||||
[$foundationItems, $policyItems] = $this->splitItems($items);
|
[$foundationItems, $policyItems] = $this->splitItems($items);
|
||||||
@ -191,11 +195,21 @@ public function execute(
|
|||||||
): RestoreRun {
|
): RestoreRun {
|
||||||
$this->assertActiveContext($tenant, $backupSet);
|
$this->assertActiveContext($tenant, $backupSet);
|
||||||
|
|
||||||
|
if ($selectedItemIds === []) {
|
||||||
|
$selectedItemIds = null;
|
||||||
|
}
|
||||||
|
|
||||||
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
|
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
|
||||||
$items = $this->loadItems($backupSet, $selectedItemIds);
|
$items = $this->loadItems($backupSet, $selectedItemIds);
|
||||||
[$foundationItems, $policyItems] = $this->splitItems($items);
|
[$foundationItems, $policyItems] = $this->splitItems($items);
|
||||||
$preview = $this->preview($tenant, $backupSet, $selectedItemIds);
|
$preview = $this->preview($tenant, $backupSet, $selectedItemIds);
|
||||||
|
|
||||||
|
$wizardMetadata = [
|
||||||
|
'scope_mode' => $selectedItemIds === null ? 'all' : 'selected',
|
||||||
|
'environment' => app()->environment('production') ? 'prod' : 'test',
|
||||||
|
'highlander_label' => (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey()),
|
||||||
|
];
|
||||||
|
|
||||||
$restoreRun = RestoreRun::create([
|
$restoreRun = RestoreRun::create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'backup_set_id' => $backupSet->id,
|
'backup_set_id' => $backupSet->id,
|
||||||
@ -205,7 +219,7 @@ public function execute(
|
|||||||
'requested_items' => $selectedItemIds,
|
'requested_items' => $selectedItemIds,
|
||||||
'preview' => $preview,
|
'preview' => $preview,
|
||||||
'started_at' => CarbonImmutable::now(),
|
'started_at' => CarbonImmutable::now(),
|
||||||
'metadata' => [],
|
'metadata' => $wizardMetadata,
|
||||||
'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
|
'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -740,12 +754,12 @@ public function execute(
|
|||||||
'status' => $status,
|
'status' => $status,
|
||||||
'results' => $results,
|
'results' => $results,
|
||||||
'completed_at' => CarbonImmutable::now(),
|
'completed_at' => CarbonImmutable::now(),
|
||||||
'metadata' => [
|
'metadata' => array_merge($restoreRun->metadata ?? [], [
|
||||||
'failed' => $hardFailures,
|
'failed' => $hardFailures,
|
||||||
'non_applied' => $nonApplied,
|
'non_applied' => $nonApplied,
|
||||||
'total' => $totalCount,
|
'total' => $totalCount,
|
||||||
'foundations_skipped' => $foundationSkipped,
|
'foundations_skipped' => $foundationSkipped,
|
||||||
],
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->auditLogger->log(
|
$this->auditLogger->log(
|
||||||
|
|||||||
73
app/Support/RestoreRunStatus.php
Normal file
73
app/Support/RestoreRunStatus.php
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
enum RestoreRunStatus: string
|
||||||
|
{
|
||||||
|
case Draft = 'draft';
|
||||||
|
case Scoped = 'scoped';
|
||||||
|
case Checked = 'checked';
|
||||||
|
case Previewed = 'previewed';
|
||||||
|
case Pending = 'pending';
|
||||||
|
case Queued = 'queued';
|
||||||
|
case Running = 'running';
|
||||||
|
case Completed = 'completed';
|
||||||
|
case Partial = 'partial';
|
||||||
|
case Failed = 'failed';
|
||||||
|
case Cancelled = 'cancelled';
|
||||||
|
|
||||||
|
// Legacy / compatibility statuses (existing housekeeping semantics)
|
||||||
|
case Aborted = 'aborted';
|
||||||
|
case CompletedWithErrors = 'completed_with_errors';
|
||||||
|
|
||||||
|
public static function fromString(?string $value): ?self
|
||||||
|
{
|
||||||
|
if ($value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = strtolower(trim($value));
|
||||||
|
$normalized = str_replace([' ', '-'], '_', $normalized);
|
||||||
|
|
||||||
|
return self::tryFrom($normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canTransitionTo(self $next): bool
|
||||||
|
{
|
||||||
|
if ($this === $next) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($this) {
|
||||||
|
self::Draft => in_array($next, [self::Scoped, self::Cancelled], true),
|
||||||
|
self::Scoped => in_array($next, [self::Checked, self::Cancelled], true),
|
||||||
|
self::Checked => in_array($next, [self::Previewed, self::Cancelled], true),
|
||||||
|
self::Previewed => in_array($next, [self::Queued, self::Cancelled], true),
|
||||||
|
self::Pending => in_array($next, [self::Queued, self::Running, self::Cancelled], true),
|
||||||
|
self::Queued => in_array($next, [self::Running, self::Cancelled], true),
|
||||||
|
self::Running => in_array($next, [self::Completed, self::Partial, self::Failed, self::Cancelled], true),
|
||||||
|
self::Completed,
|
||||||
|
self::Partial,
|
||||||
|
self::Failed,
|
||||||
|
self::Cancelled,
|
||||||
|
self::Aborted,
|
||||||
|
self::CompletedWithErrors => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isDeletable(): bool
|
||||||
|
{
|
||||||
|
return in_array($this, [
|
||||||
|
self::Draft,
|
||||||
|
self::Scoped,
|
||||||
|
self::Checked,
|
||||||
|
self::Previewed,
|
||||||
|
self::Completed,
|
||||||
|
self::Partial,
|
||||||
|
self::Failed,
|
||||||
|
self::Cancelled,
|
||||||
|
self::Aborted,
|
||||||
|
self::CompletedWithErrors,
|
||||||
|
], true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,13 +7,13 @@ ## Phase 0 — Specs (this PR)
|
|||||||
- [x] T001 Create `spec.md`, `plan.md`, `tasks.md` for Feature 011.
|
- [x] T001 Create `spec.md`, `plan.md`, `tasks.md` for Feature 011.
|
||||||
|
|
||||||
## Phase 1 — Data Model + Status Semantics
|
## Phase 1 — Data Model + Status Semantics
|
||||||
- [ ] T002 Define RestoreRun lifecycle statuses and transitions (draft→scoped→checked→previewed→queued→running→completed|partial|failed).
|
- [x] T002 Define RestoreRun lifecycle statuses and transitions (draft→scoped→checked→previewed→queued→running→completed|partial|failed).
|
||||||
- [ ] T003 Add minimal persistence for wizard state (prefer JSON in `restore_runs.metadata` unless columns are required).
|
- [x] T003 Add minimal persistence for wizard state (prefer JSON in `restore_runs.metadata` unless columns are required).
|
||||||
- [ ] T004 Freeze `environment` + `highlander_label` at run creation for audit.
|
- [x] T004 Freeze `environment` + `highlander_label` at run creation for audit.
|
||||||
|
|
||||||
## Phase 2 — Filament Wizard (Create Restore Run)
|
## Phase 2 — Filament Wizard (Create Restore Run)
|
||||||
- [ ] T005 Replace current single-form create with a 5-step wizard (Step 1–5 as in spec).
|
- [x] T005 Replace current single-form create with a 5-step wizard (Step 1–5 as in spec).
|
||||||
- [ ] T006 Ensure changing `backup_set_id` resets downstream wizard state.
|
- [x] T006 Ensure changing `backup_set_id` resets downstream wizard state.
|
||||||
- [ ] T007 Enforce “dry-run default ON” and keep execute disabled until all gates satisfied.
|
- [ ] T007 Enforce “dry-run default ON” and keep execute disabled until all gates satisfied.
|
||||||
|
|
||||||
## Phase 3 — Restore Scope UX
|
## Phase 3 — Restore Scope UX
|
||||||
@ -40,4 +40,3 @@ ## Phase 7 — Tests + Formatting
|
|||||||
- [ ] T020 Add Pest tests for preview summary generation.
|
- [ ] T020 Add Pest tests for preview summary generation.
|
||||||
- [ ] T021 Run `./vendor/bin/pint --dirty`.
|
- [ ] T021 Run `./vendor/bin/pint --dirty`.
|
||||||
- [ ] T022 Run targeted tests (e.g. `./vendor/bin/sail artisan test --filter=RestoreRunWizard` once tests exist).
|
- [ ] T022 Run targeted tests (e.g. `./vendor/bin/sail artisan test --filter=RestoreRunWizard` once tests exist).
|
||||||
|
|
||||||
|
|||||||
@ -84,6 +84,10 @@
|
|||||||
->fillForm([
|
->fillForm([
|
||||||
'backup_set_id' => $backupSet->id,
|
'backup_set_id' => $backupSet->id,
|
||||||
])
|
])
|
||||||
|
->goToNextWizardStep()
|
||||||
|
->fillForm([
|
||||||
|
'scope_mode' => 'selected',
|
||||||
|
])
|
||||||
->assertSee('Policy Display')
|
->assertSee('Policy Display')
|
||||||
->assertDontSee('Ignored Policy')
|
->assertDontSee('Ignored Policy')
|
||||||
->assertSee('Scope Tag Alpha')
|
->assertSee('Scope Tag Alpha')
|
||||||
|
|||||||
@ -80,6 +80,10 @@
|
|||||||
Livewire::test(CreateRestoreRun::class)
|
Livewire::test(CreateRestoreRun::class)
|
||||||
->fillForm([
|
->fillForm([
|
||||||
'backup_set_id' => $backupSet->id,
|
'backup_set_id' => $backupSet->id,
|
||||||
|
])
|
||||||
|
->goToNextWizardStep()
|
||||||
|
->fillForm([
|
||||||
|
'scope_mode' => 'selected',
|
||||||
'backup_item_ids' => [$backupItem->id],
|
'backup_item_ids' => [$backupItem->id],
|
||||||
])
|
])
|
||||||
->assertFormFieldVisible('group_mapping.source-group-1');
|
->assertFormFieldVisible('group_mapping.source-group-1');
|
||||||
@ -150,12 +154,21 @@
|
|||||||
Livewire::test(CreateRestoreRun::class)
|
Livewire::test(CreateRestoreRun::class)
|
||||||
->fillForm([
|
->fillForm([
|
||||||
'backup_set_id' => $backupSet->id,
|
'backup_set_id' => $backupSet->id,
|
||||||
|
])
|
||||||
|
->goToNextWizardStep()
|
||||||
|
->fillForm([
|
||||||
|
'scope_mode' => 'selected',
|
||||||
'backup_item_ids' => [$backupItem->id],
|
'backup_item_ids' => [$backupItem->id],
|
||||||
'group_mapping' => [
|
'group_mapping' => [
|
||||||
'source-group-1' => 'target-group-1',
|
'source-group-1' => 'target-group-1',
|
||||||
],
|
],
|
||||||
|
])
|
||||||
|
->goToNextWizardStep()
|
||||||
|
->goToNextWizardStep()
|
||||||
|
->fillForm([
|
||||||
'is_dry_run' => true,
|
'is_dry_run' => true,
|
||||||
])
|
])
|
||||||
|
->goToNextWizardStep()
|
||||||
->call('create')
|
->call('create')
|
||||||
->assertHasNoFormErrors();
|
->assertHasNoFormErrors();
|
||||||
|
|
||||||
|
|||||||
88
tests/Feature/RestoreRunWizardMetadataTest.php
Normal file
88
tests/Feature/RestoreRunWizardMetadataTest.php
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
|
||||||
|
use App\Models\BackupItem;
|
||||||
|
use App\Models\BackupSet;
|
||||||
|
use App\Models\RestoreRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
putenv('INTUNE_TENANT_ID');
|
||||||
|
unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restore run stores wizard audit metadata and preserves it on completion', function () {
|
||||||
|
$tenant = Tenant::create([
|
||||||
|
'tenant_id' => 'tenant-1',
|
||||||
|
'name' => 'Tenant One',
|
||||||
|
'metadata' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
|
||||||
|
$backupSet = BackupSet::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'name' => 'Backup',
|
||||||
|
'status' => 'completed',
|
||||||
|
'item_count' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$backupItem = BackupItem::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
'policy_id' => null,
|
||||||
|
'policy_identifier' => 'policy-1',
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'platform' => 'windows',
|
||||||
|
'payload' => ['id' => 'policy-1'],
|
||||||
|
'metadata' => [
|
||||||
|
'displayName' => 'Backup Policy One',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'email' => 'tester@example.com',
|
||||||
|
'name' => 'Tester',
|
||||||
|
]);
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
Livewire::test(CreateRestoreRun::class)
|
||||||
|
->fillForm([
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
])
|
||||||
|
->goToNextWizardStep()
|
||||||
|
->fillForm([
|
||||||
|
'scope_mode' => 'selected',
|
||||||
|
'backup_item_ids' => [$backupItem->id],
|
||||||
|
])
|
||||||
|
->goToNextWizardStep()
|
||||||
|
->goToNextWizardStep()
|
||||||
|
->fillForm([
|
||||||
|
'is_dry_run' => true,
|
||||||
|
])
|
||||||
|
->goToNextWizardStep()
|
||||||
|
->call('create')
|
||||||
|
->assertHasNoFormErrors();
|
||||||
|
|
||||||
|
$run = RestoreRun::query()->latest('id')->first();
|
||||||
|
|
||||||
|
expect($run)->not->toBeNull();
|
||||||
|
expect($run->metadata)->toHaveKeys([
|
||||||
|
'scope_mode',
|
||||||
|
'environment',
|
||||||
|
'highlander_label',
|
||||||
|
'failed',
|
||||||
|
'non_applied',
|
||||||
|
'total',
|
||||||
|
'foundations_skipped',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($run->metadata['scope_mode'])->toBe('selected');
|
||||||
|
expect($run->metadata['environment'])->toBe('test');
|
||||||
|
expect($run->metadata['highlander_label'])->toBe('Tenant One');
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user