feat/012-windows-update-rings #18
@ -13,6 +13,7 @@
|
|||||||
use App\Services\BulkOperationService;
|
use App\Services\BulkOperationService;
|
||||||
use App\Services\Graph\GraphClientInterface;
|
use App\Services\Graph\GraphClientInterface;
|
||||||
use App\Services\Graph\GroupResolver;
|
use App\Services\Graph\GroupResolver;
|
||||||
|
use App\Services\Intune\RestoreDiffGenerator;
|
||||||
use App\Services\Intune\RestoreRiskChecker;
|
use App\Services\Intune\RestoreRiskChecker;
|
||||||
use App\Services\Intune\RestoreService;
|
use App\Services\Intune\RestoreService;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
@ -180,6 +181,9 @@ public static function getWizardSteps(): array
|
|||||||
$set('check_summary', null);
|
$set('check_summary', null);
|
||||||
$set('check_results', []);
|
$set('check_results', []);
|
||||||
$set('checks_ran_at', null);
|
$set('checks_ran_at', null);
|
||||||
|
$set('preview_summary', null);
|
||||||
|
$set('preview_diffs', []);
|
||||||
|
$set('preview_ran_at', null);
|
||||||
})
|
})
|
||||||
->required(),
|
->required(),
|
||||||
]),
|
]),
|
||||||
@ -199,6 +203,9 @@ public static function getWizardSteps(): array
|
|||||||
$set('check_summary', null);
|
$set('check_summary', null);
|
||||||
$set('check_results', []);
|
$set('check_results', []);
|
||||||
$set('checks_ran_at', null);
|
$set('checks_ran_at', null);
|
||||||
|
$set('preview_summary', null);
|
||||||
|
$set('preview_diffs', []);
|
||||||
|
$set('preview_ran_at', null);
|
||||||
|
|
||||||
if ($state === 'all') {
|
if ($state === 'all') {
|
||||||
$set('backup_item_ids', null);
|
$set('backup_item_ids', null);
|
||||||
@ -223,6 +230,9 @@ public static function getWizardSteps(): array
|
|||||||
$set('check_summary', null);
|
$set('check_summary', null);
|
||||||
$set('check_results', []);
|
$set('check_results', []);
|
||||||
$set('checks_ran_at', null);
|
$set('checks_ran_at', null);
|
||||||
|
$set('preview_summary', null);
|
||||||
|
$set('preview_diffs', []);
|
||||||
|
$set('preview_ran_at', null);
|
||||||
})
|
})
|
||||||
->visible(fn (Get $get): bool => $get('scope_mode') === 'selected')
|
->visible(fn (Get $get): bool => $get('scope_mode') === 'selected')
|
||||||
->required(fn (Get $get): bool => $get('scope_mode') === 'selected')
|
->required(fn (Get $get): bool => $get('scope_mode') === 'selected')
|
||||||
@ -292,6 +302,9 @@ public static function getWizardSteps(): array
|
|||||||
$set('check_summary', null);
|
$set('check_summary', null);
|
||||||
$set('check_results', []);
|
$set('check_results', []);
|
||||||
$set('checks_ran_at', null);
|
$set('checks_ran_at', null);
|
||||||
|
$set('preview_summary', null);
|
||||||
|
$set('preview_diffs', []);
|
||||||
|
$set('preview_ran_at', null);
|
||||||
})
|
})
|
||||||
->helperText('Choose a target group or select Skip.');
|
->helperText('Choose a target group or select Skip.');
|
||||||
}, $unresolved);
|
}, $unresolved);
|
||||||
@ -414,16 +427,99 @@ public static function getWizardSteps(): array
|
|||||||
->helperText('Run checks after defining scope and mapping missing groups.'),
|
->helperText('Run checks after defining scope and mapping missing groups.'),
|
||||||
]),
|
]),
|
||||||
Step::make('Preview')
|
Step::make('Preview')
|
||||||
->description('Dry-run preview (Phase 5)')
|
->description('Dry-run preview')
|
||||||
->schema([
|
->schema([
|
||||||
Forms\Components\Toggle::make('is_dry_run')
|
Forms\Components\Toggle::make('is_dry_run')
|
||||||
->label('Preview only (dry-run)')
|
->label('Preview only (dry-run)')
|
||||||
->default(true)
|
->default(true)
|
||||||
->disabled()
|
->disabled()
|
||||||
->helperText('Execution will be enabled once checks, preview, and confirmations are implemented (Phase 6).'),
|
->helperText('Execution will be enabled once checks, preview, and confirmations are implemented (Phase 6).'),
|
||||||
Forms\Components\Placeholder::make('preview_placeholder')
|
Forms\Components\Hidden::make('preview_summary')
|
||||||
|
->default(null),
|
||||||
|
Forms\Components\Hidden::make('preview_ran_at')
|
||||||
|
->default(null)
|
||||||
|
->required(),
|
||||||
|
Forms\Components\ViewField::make('preview_diffs')
|
||||||
->label('Preview')
|
->label('Preview')
|
||||||
->content('Preview diff summary will be added in Phase 5.'),
|
->default([])
|
||||||
|
->view('filament.forms.components.restore-run-preview')
|
||||||
|
->viewData(fn (Get $get): array => [
|
||||||
|
'summary' => $get('preview_summary'),
|
||||||
|
'ranAt' => $get('preview_ran_at'),
|
||||||
|
])
|
||||||
|
->hintActions([
|
||||||
|
Actions\Action::make('run_restore_preview')
|
||||||
|
->label('Generate preview')
|
||||||
|
->icon('heroicon-o-eye')
|
||||||
|
->color('gray')
|
||||||
|
->visible(fn (Get $get): bool => filled($get('backup_set_id')))
|
||||||
|
->action(function (Get $get, Set $set): void {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$backupSetId = $get('backup_set_id');
|
||||||
|
|
||||||
|
if (! $backupSetId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$backupSet = BackupSet::find($backupSetId);
|
||||||
|
|
||||||
|
if (! $backupSet || $backupSet->tenant_id !== $tenant->id) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Unable to generate preview')
|
||||||
|
->body('Backup set is not available for the active tenant.')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scopeMode = $get('scope_mode') ?? 'all';
|
||||||
|
$selectedItemIds = ($scopeMode === 'selected')
|
||||||
|
? ($get('backup_item_ids') ?? null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
|
||||||
|
|
||||||
|
$generator = app(RestoreDiffGenerator::class);
|
||||||
|
$outcome = $generator->generate(
|
||||||
|
tenant: $tenant,
|
||||||
|
backupSet: $backupSet,
|
||||||
|
selectedItemIds: $selectedItemIds,
|
||||||
|
);
|
||||||
|
|
||||||
|
$summary = $outcome['summary'] ?? [];
|
||||||
|
$diffs = $outcome['diffs'] ?? [];
|
||||||
|
|
||||||
|
$set('preview_summary', $summary, shouldCallUpdatedHooks: true);
|
||||||
|
$set('preview_diffs', $diffs, shouldCallUpdatedHooks: true);
|
||||||
|
$set('preview_ran_at', $summary['generated_at'] ?? now()->toIso8601String(), shouldCallUpdatedHooks: true);
|
||||||
|
|
||||||
|
$policiesChanged = (int) ($summary['policies_changed'] ?? 0);
|
||||||
|
$policiesTotal = (int) ($summary['policies_total'] ?? 0);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Preview generated')
|
||||||
|
->body("Policies: {$policiesChanged}/{$policiesTotal} changed")
|
||||||
|
->status($policiesChanged > 0 ? 'warning' : 'success')
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
|
Actions\Action::make('clear_restore_preview')
|
||||||
|
->label('Clear')
|
||||||
|
->icon('heroicon-o-x-mark')
|
||||||
|
->color('gray')
|
||||||
|
->visible(fn (Get $get): bool => filled($get('preview_diffs')) || filled($get('preview_summary')))
|
||||||
|
->action(function (Set $set): void {
|
||||||
|
$set('preview_summary', null, shouldCallUpdatedHooks: true);
|
||||||
|
$set('preview_diffs', [], shouldCallUpdatedHooks: true);
|
||||||
|
$set('preview_ran_at', null, shouldCallUpdatedHooks: true);
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
->helperText('Generate a normalized diff preview before creating the dry-run restore.'),
|
||||||
]),
|
]),
|
||||||
Step::make('Confirm & Execute')
|
Step::make('Confirm & Execute')
|
||||||
->description('Explicit confirmations (Phase 6)')
|
->description('Explicit confirmations (Phase 6)')
|
||||||
@ -976,8 +1072,18 @@ public static function createRestoreRun(array $data): RestoreRun
|
|||||||
$checkSummary = $data['check_summary'] ?? null;
|
$checkSummary = $data['check_summary'] ?? null;
|
||||||
$checkResults = $data['check_results'] ?? null;
|
$checkResults = $data['check_results'] ?? null;
|
||||||
$checksRanAt = $data['checks_ran_at'] ?? null;
|
$checksRanAt = $data['checks_ran_at'] ?? null;
|
||||||
|
$previewSummary = $data['preview_summary'] ?? null;
|
||||||
|
$previewDiffs = $data['preview_diffs'] ?? null;
|
||||||
|
$previewRanAt = $data['preview_ran_at'] ?? null;
|
||||||
|
|
||||||
if (is_array($checkSummary) || is_array($checkResults) || (is_string($checksRanAt) && $checksRanAt !== '')) {
|
if (
|
||||||
|
is_array($checkSummary)
|
||||||
|
|| is_array($checkResults)
|
||||||
|
|| (is_string($checksRanAt) && $checksRanAt !== '')
|
||||||
|
|| is_array($previewSummary)
|
||||||
|
|| is_array($previewDiffs)
|
||||||
|
|| (is_string($previewRanAt) && $previewRanAt !== '')
|
||||||
|
) {
|
||||||
$metadata = $restoreRun->metadata ?? [];
|
$metadata = $restoreRun->metadata ?? [];
|
||||||
|
|
||||||
if (is_array($checkSummary)) {
|
if (is_array($checkSummary)) {
|
||||||
@ -992,6 +1098,18 @@ public static function createRestoreRun(array $data): RestoreRun
|
|||||||
$metadata['checks_ran_at'] = $checksRanAt;
|
$metadata['checks_ran_at'] = $checksRanAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (is_array($previewSummary)) {
|
||||||
|
$metadata['preview_summary'] = $previewSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($previewDiffs)) {
|
||||||
|
$metadata['preview_diffs'] = $previewDiffs;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($previewRanAt) && $previewRanAt !== '') {
|
||||||
|
$metadata['preview_ran_at'] = $previewRanAt;
|
||||||
|
}
|
||||||
|
|
||||||
$restoreRun->update([
|
$restoreRun->update([
|
||||||
'metadata' => $metadata,
|
'metadata' => $metadata,
|
||||||
]);
|
]);
|
||||||
|
|||||||
248
app/Services/Intune/RestoreDiffGenerator.php
Normal file
248
app/Services/Intune/RestoreDiffGenerator.php
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Intune;
|
||||||
|
|
||||||
|
use App\Models\BackupItem;
|
||||||
|
use App\Models\BackupSet;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class RestoreDiffGenerator
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly PolicyNormalizer $policyNormalizer,
|
||||||
|
private readonly VersionDiff $versionDiff,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int>|null $selectedItemIds
|
||||||
|
* @return array{summary: array<string, mixed>, diffs: array<int, array<string, mixed>>}
|
||||||
|
*/
|
||||||
|
public function generate(Tenant $tenant, BackupSet $backupSet, ?array $selectedItemIds = null): array
|
||||||
|
{
|
||||||
|
if ($backupSet->tenant_id !== $tenant->id) {
|
||||||
|
throw new \InvalidArgumentException('Backup set does not belong to the provided tenant.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($selectedItemIds === []) {
|
||||||
|
$selectedItemIds = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = $this->loadItems($backupSet, $selectedItemIds);
|
||||||
|
$policyItems = $items
|
||||||
|
->reject(fn (BackupItem $item): bool => $item->isFoundation())
|
||||||
|
->values();
|
||||||
|
|
||||||
|
$policyIds = $policyItems
|
||||||
|
->pluck('policy_id')
|
||||||
|
->filter()
|
||||||
|
->unique()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$latestVersions = $this->latestVersionsByPolicyId($tenant, $policyIds);
|
||||||
|
|
||||||
|
$maxDetailedDiffs = 25;
|
||||||
|
$maxEntriesPerSection = 200;
|
||||||
|
|
||||||
|
$policiesChanged = 0;
|
||||||
|
$assignmentsChanged = 0;
|
||||||
|
$scopeTagsChanged = 0;
|
||||||
|
|
||||||
|
$diffs = [];
|
||||||
|
$diffsOmitted = 0;
|
||||||
|
|
||||||
|
foreach ($policyItems as $index => $item) {
|
||||||
|
$policyId = $item->policy_id ? (int) $item->policy_id : null;
|
||||||
|
$currentVersion = $policyId ? ($latestVersions[$policyId] ?? null) : null;
|
||||||
|
|
||||||
|
$currentSnapshot = is_array($currentVersion?->snapshot) ? $currentVersion->snapshot : [];
|
||||||
|
$backupSnapshot = is_array($item->payload) ? $item->payload : [];
|
||||||
|
|
||||||
|
$policyType = (string) ($item->policy_type ?? '');
|
||||||
|
$platform = $item->platform;
|
||||||
|
|
||||||
|
$from = $this->policyNormalizer->flattenForDiff($currentSnapshot, $policyType, $platform);
|
||||||
|
$to = $this->policyNormalizer->flattenForDiff($backupSnapshot, $policyType, $platform);
|
||||||
|
|
||||||
|
$diff = $this->versionDiff->compare($from, $to);
|
||||||
|
$summary = $diff['summary'] ?? ['added' => 0, 'removed' => 0, 'changed' => 0];
|
||||||
|
|
||||||
|
$hasPolicyChanges = ((int) ($summary['added'] ?? 0) + (int) ($summary['removed'] ?? 0) + (int) ($summary['changed'] ?? 0)) > 0;
|
||||||
|
|
||||||
|
if ($hasPolicyChanges) {
|
||||||
|
$policiesChanged++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$assignmentDiff = $this->assignmentsChanged($item->assignments, $currentVersion?->assignments);
|
||||||
|
if ($assignmentDiff) {
|
||||||
|
$assignmentsChanged++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scopeTagDiff = $this->scopeTagsChanged($item, $currentVersion);
|
||||||
|
if ($scopeTagDiff) {
|
||||||
|
$scopeTagsChanged++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$diffEntry = [
|
||||||
|
'backup_item_id' => $item->id,
|
||||||
|
'display_name' => $item->resolvedDisplayName(),
|
||||||
|
'policy_identifier' => $item->policy_identifier,
|
||||||
|
'policy_type' => $policyType,
|
||||||
|
'platform' => $platform,
|
||||||
|
'action' => $currentVersion ? 'update' : 'create',
|
||||||
|
'diff' => [
|
||||||
|
'summary' => $summary,
|
||||||
|
'added' => [],
|
||||||
|
'removed' => [],
|
||||||
|
'changed' => [],
|
||||||
|
],
|
||||||
|
'assignments_changed' => $assignmentDiff,
|
||||||
|
'scope_tags_changed' => $scopeTagDiff,
|
||||||
|
'diff_omitted' => false,
|
||||||
|
'diff_truncated' => false,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($index >= $maxDetailedDiffs) {
|
||||||
|
$diffEntry['diff_omitted'] = true;
|
||||||
|
$diffEntry['diff_truncated'] = true;
|
||||||
|
$diffEntry['diff'] = [
|
||||||
|
'summary' => $summary,
|
||||||
|
];
|
||||||
|
$diffsOmitted++;
|
||||||
|
$diffs[] = $diffEntry;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$added = is_array($diff['added'] ?? null) ? $diff['added'] : [];
|
||||||
|
$removed = is_array($diff['removed'] ?? null) ? $diff['removed'] : [];
|
||||||
|
$changed = is_array($diff['changed'] ?? null) ? $diff['changed'] : [];
|
||||||
|
|
||||||
|
$diffEntry['diff_truncated'] = count($added) > $maxEntriesPerSection
|
||||||
|
|| count($removed) > $maxEntriesPerSection
|
||||||
|
|| count($changed) > $maxEntriesPerSection;
|
||||||
|
|
||||||
|
$diffEntry['diff'] = [
|
||||||
|
'summary' => $summary,
|
||||||
|
'added' => array_slice($added, 0, $maxEntriesPerSection, true),
|
||||||
|
'removed' => array_slice($removed, 0, $maxEntriesPerSection, true),
|
||||||
|
'changed' => array_slice($changed, 0, $maxEntriesPerSection, true),
|
||||||
|
];
|
||||||
|
|
||||||
|
$diffs[] = $diffEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'summary' => [
|
||||||
|
'generated_at' => CarbonImmutable::now()->toIso8601String(),
|
||||||
|
'policies_total' => $policyItems->count(),
|
||||||
|
'policies_changed' => $policiesChanged,
|
||||||
|
'assignments_changed' => $assignmentsChanged,
|
||||||
|
'scope_tags_changed' => $scopeTagsChanged,
|
||||||
|
'diffs_detailed' => min($policyItems->count(), $maxDetailedDiffs),
|
||||||
|
'diffs_omitted' => $diffsOmitted,
|
||||||
|
'limits' => [
|
||||||
|
'max_detailed_diffs' => $maxDetailedDiffs,
|
||||||
|
'max_entries_per_section' => $maxEntriesPerSection,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'diffs' => $diffs,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int>|null $selectedItemIds
|
||||||
|
* @return Collection<int, BackupItem>
|
||||||
|
*/
|
||||||
|
private function loadItems(BackupSet $backupSet, ?array $selectedItemIds): Collection
|
||||||
|
{
|
||||||
|
$query = $backupSet->items()->getQuery();
|
||||||
|
|
||||||
|
if ($selectedItemIds !== null) {
|
||||||
|
$query->whereIn('id', $selectedItemIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->orderBy('id')->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, int> $policyIds
|
||||||
|
* @return array<int, PolicyVersion>
|
||||||
|
*/
|
||||||
|
private function latestVersionsByPolicyId(Tenant $tenant, array $policyIds): array
|
||||||
|
{
|
||||||
|
if ($policyIds === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$latestVersionsQuery = PolicyVersion::query()
|
||||||
|
->where('tenant_id', $tenant->id)
|
||||||
|
->whereIn('policy_id', $policyIds)
|
||||||
|
->selectRaw('policy_id, max(version_number) as version_number')
|
||||||
|
->groupBy('policy_id');
|
||||||
|
|
||||||
|
return PolicyVersion::query()
|
||||||
|
->where('tenant_id', $tenant->id)
|
||||||
|
->joinSub($latestVersionsQuery, 'latest_versions', function ($join): void {
|
||||||
|
$join->on('policy_versions.policy_id', '=', 'latest_versions.policy_id')
|
||||||
|
->on('policy_versions.version_number', '=', 'latest_versions.version_number');
|
||||||
|
})
|
||||||
|
->get()
|
||||||
|
->keyBy('policy_id')
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assignmentsChanged(?array $backupAssignments, ?array $currentAssignments): bool
|
||||||
|
{
|
||||||
|
$backup = $this->normalizeAssignments($backupAssignments);
|
||||||
|
$current = $this->normalizeAssignments($currentAssignments);
|
||||||
|
|
||||||
|
return $backup !== $current;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function scopeTagsChanged(BackupItem $backupItem, ?PolicyVersion $currentVersion): bool
|
||||||
|
{
|
||||||
|
$backupIds = $backupItem->scope_tag_ids;
|
||||||
|
$backupIds = is_array($backupIds) ? $backupIds : [];
|
||||||
|
$backupIds = array_values(array_filter($backupIds, fn (mixed $id): bool => is_string($id) && $id !== '' && $id !== '0'));
|
||||||
|
sort($backupIds);
|
||||||
|
|
||||||
|
$scopeTags = $currentVersion?->scope_tags;
|
||||||
|
$currentIds = is_array($scopeTags) ? ($scopeTags['ids'] ?? []) : [];
|
||||||
|
$currentIds = is_array($currentIds) ? $currentIds : [];
|
||||||
|
$currentIds = array_values(array_filter($currentIds, fn (mixed $id): bool => is_string($id) && $id !== '' && $id !== '0'));
|
||||||
|
sort($currentIds);
|
||||||
|
|
||||||
|
return $backupIds !== $currentIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function normalizeAssignments(?array $assignments): array
|
||||||
|
{
|
||||||
|
$assignments = is_array($assignments) ? $assignments : [];
|
||||||
|
|
||||||
|
$normalized = [];
|
||||||
|
|
||||||
|
foreach ($assignments as $assignment) {
|
||||||
|
if (! is_array($assignment)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized[] = $assignment;
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($normalized, function (array $a, array $b): int {
|
||||||
|
$left = json_encode($a, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '';
|
||||||
|
$right = json_encode($b, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '';
|
||||||
|
|
||||||
|
return $left <=> $right;
|
||||||
|
});
|
||||||
|
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,177 @@
|
|||||||
|
@php
|
||||||
|
$diffs = $getState() ?? [];
|
||||||
|
$diffs = is_array($diffs) ? $diffs : [];
|
||||||
|
|
||||||
|
$summary = $summary ?? [];
|
||||||
|
$summary = is_array($summary) ? $summary : [];
|
||||||
|
|
||||||
|
$ranAt = $ranAt ?? null;
|
||||||
|
$ranAtLabel = null;
|
||||||
|
|
||||||
|
if (is_string($ranAt) && $ranAt !== '') {
|
||||||
|
try {
|
||||||
|
$ranAtLabel = \Carbon\CarbonImmutable::parse($ranAt)->format('Y-m-d H:i');
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$ranAtLabel = $ranAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$policiesTotal = (int) ($summary['policies_total'] ?? 0);
|
||||||
|
$policiesChanged = (int) ($summary['policies_changed'] ?? 0);
|
||||||
|
$assignmentsChanged = (int) ($summary['assignments_changed'] ?? 0);
|
||||||
|
$scopeTagsChanged = (int) ($summary['scope_tags_changed'] ?? 0);
|
||||||
|
$diffsOmitted = (int) ($summary['diffs_omitted'] ?? 0);
|
||||||
|
|
||||||
|
$limitedKeys = static function (array $items, int $limit = 8): array {
|
||||||
|
$keys = array_keys($items);
|
||||||
|
|
||||||
|
if (count($keys) <= $limit) {
|
||||||
|
return $keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_slice($keys, 0, $limit);
|
||||||
|
};
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<x-filament::section
|
||||||
|
heading="Preview"
|
||||||
|
:description="$ranAtLabel ? ('Generated: ' . $ranAtLabel) : 'Generate a preview to see what would change.'"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<x-filament::badge :color="$policiesChanged > 0 ? 'warning' : 'success'">
|
||||||
|
{{ $policiesChanged }}/{{ $policiesTotal }} policies changed
|
||||||
|
</x-filament::badge>
|
||||||
|
<x-filament::badge :color="$assignmentsChanged > 0 ? 'warning' : 'gray'">
|
||||||
|
{{ $assignmentsChanged }} assignments changed
|
||||||
|
</x-filament::badge>
|
||||||
|
<x-filament::badge :color="$scopeTagsChanged > 0 ? 'warning' : 'gray'">
|
||||||
|
{{ $scopeTagsChanged }} scope tags changed
|
||||||
|
</x-filament::badge>
|
||||||
|
@if ($diffsOmitted > 0)
|
||||||
|
<x-filament::badge color="gray">
|
||||||
|
{{ $diffsOmitted }} diffs omitted (limit)
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
|
@if ($diffs === [])
|
||||||
|
<x-filament::section>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
No preview generated yet.
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
@else
|
||||||
|
<div class="space-y-3">
|
||||||
|
@foreach ($diffs as $entry)
|
||||||
|
@php
|
||||||
|
$entry = is_array($entry) ? $entry : [];
|
||||||
|
$name = $entry['display_name'] ?? $entry['policy_identifier'] ?? 'Item';
|
||||||
|
$type = $entry['policy_type'] ?? 'type';
|
||||||
|
$platform = $entry['platform'] ?? 'platform';
|
||||||
|
$action = $entry['action'] ?? 'update';
|
||||||
|
$diff = is_array($entry['diff'] ?? null) ? $entry['diff'] : [];
|
||||||
|
$diffSummary = is_array($diff['summary'] ?? null) ? $diff['summary'] : [];
|
||||||
|
|
||||||
|
$added = (int) ($diffSummary['added'] ?? 0);
|
||||||
|
$removed = (int) ($diffSummary['removed'] ?? 0);
|
||||||
|
$changed = (int) ($diffSummary['changed'] ?? 0);
|
||||||
|
|
||||||
|
$assignmentsDelta = (bool) ($entry['assignments_changed'] ?? false);
|
||||||
|
$scopeTagsDelta = (bool) ($entry['scope_tags_changed'] ?? false);
|
||||||
|
$diffOmitted = (bool) ($entry['diff_omitted'] ?? false);
|
||||||
|
$diffTruncated = (bool) ($entry['diff_truncated'] ?? false);
|
||||||
|
|
||||||
|
$changedKeys = $limitedKeys(is_array($diff['changed'] ?? null) ? $diff['changed'] : []);
|
||||||
|
$addedKeys = $limitedKeys(is_array($diff['added'] ?? null) ? $diff['added'] : []);
|
||||||
|
$removedKeys = $limitedKeys(is_array($diff['removed'] ?? null) ? $diff['removed'] : []);
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<x-filament::section :heading="$name" :description="sprintf('%s • %s', $type, $platform)" collapsible :collapsed="true">
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<x-filament::badge :color="$action === 'create' ? 'success' : 'gray'" size="sm">
|
||||||
|
{{ $action }}
|
||||||
|
</x-filament::badge>
|
||||||
|
<x-filament::badge color="success" size="sm">
|
||||||
|
{{ $added }} added
|
||||||
|
</x-filament::badge>
|
||||||
|
<x-filament::badge color="danger" size="sm">
|
||||||
|
{{ $removed }} removed
|
||||||
|
</x-filament::badge>
|
||||||
|
<x-filament::badge color="warning" size="sm">
|
||||||
|
{{ $changed }} changed
|
||||||
|
</x-filament::badge>
|
||||||
|
@if ($assignmentsDelta)
|
||||||
|
<x-filament::badge color="warning" size="sm">
|
||||||
|
assignments
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
@if ($scopeTagsDelta)
|
||||||
|
<x-filament::badge color="warning" size="sm">
|
||||||
|
scope tags
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
@if ($diffTruncated)
|
||||||
|
<x-filament::badge color="gray" size="sm">
|
||||||
|
truncated
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($diffOmitted)
|
||||||
|
<div class="mt-3 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Diff details omitted due to preview limits. Narrow scope to see more items in detail.
|
||||||
|
</div>
|
||||||
|
@elseif ($changedKeys !== [] || $addedKeys !== [] || $removedKeys !== [])
|
||||||
|
<div class="mt-3 space-y-3 text-sm text-gray-700 dark:text-gray-200">
|
||||||
|
@if ($changedKeys !== [])
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
Changed keys (sample)
|
||||||
|
</div>
|
||||||
|
<ul class="mt-1 space-y-1">
|
||||||
|
@foreach ($changedKeys as $key)
|
||||||
|
<li class="rounded bg-gray-50 px-2 py-1 text-xs text-gray-800 dark:bg-white/5 dark:text-gray-200">
|
||||||
|
{{ $key }}
|
||||||
|
</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@if ($addedKeys !== [])
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
Added keys (sample)
|
||||||
|
</div>
|
||||||
|
<ul class="mt-1 space-y-1">
|
||||||
|
@foreach ($addedKeys as $key)
|
||||||
|
<li class="rounded bg-gray-50 px-2 py-1 text-xs text-gray-800 dark:bg-white/5 dark:text-gray-200">
|
||||||
|
{{ $key }}
|
||||||
|
</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@if ($removedKeys !== [])
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
Removed keys (sample)
|
||||||
|
</div>
|
||||||
|
<ul class="mt-1 space-y-1">
|
||||||
|
@foreach ($removedKeys as $key)
|
||||||
|
<li class="rounded bg-gray-50 px-2 py-1 text-xs text-gray-800 dark:bg-white/5 dark:text-gray-200">
|
||||||
|
{{ $key }}
|
||||||
|
</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</x-filament::section>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
@ -26,8 +26,8 @@ ## Phase 4 — Safety & Conflict Checks
|
|||||||
- [x] T012 Render check results with severity (blocking/warning/safe) and block execute when blockers exist.
|
- [x] T012 Render check results with severity (blocking/warning/safe) and block execute when blockers exist.
|
||||||
|
|
||||||
## Phase 5 — Preview (Diff)
|
## Phase 5 — Preview (Diff)
|
||||||
- [ ] T013 Implement `RestoreDiffGenerator` using `PolicyNormalizer` + `VersionDiff`.
|
- [x] T013 Implement `RestoreDiffGenerator` using `PolicyNormalizer` + `VersionDiff`.
|
||||||
- [ ] T014 Persist preview summary (and per-item diffs with safe limits) and require preview completion before execute.
|
- [x] T014 Persist preview summary (and per-item diffs with safe limits) and require preview completion before execute.
|
||||||
|
|
||||||
## Phase 6 — Confirm & Execute
|
## Phase 6 — Confirm & Execute
|
||||||
- [ ] T015 Implement Step 5 confirmations (ack checkbox + tenant hard-confirm).
|
- [ ] T015 Implement Step 5 confirmations (ack checkbox + tenant hard-confirm).
|
||||||
@ -36,8 +36,8 @@ ## Phase 6 — Confirm & Execute
|
|||||||
|
|
||||||
## Phase 7 — Tests + Formatting
|
## Phase 7 — Tests + Formatting
|
||||||
- [ ] T018 Add Pest tests for wizard gating rules and status transitions.
|
- [ ] T018 Add Pest tests for wizard gating rules and status transitions.
|
||||||
- [ ] T019 Add Pest tests for safety checks persistence and blocking behavior.
|
- [x] T019 Add Pest tests for safety checks persistence and blocking behavior.
|
||||||
- [ ] T020 Add Pest tests for preview summary generation.
|
- [x] T020 Add Pest tests for preview summary generation.
|
||||||
- [x] T021 Run `./vendor/bin/pint --dirty`.
|
- [x] T021 Run `./vendor/bin/pint --dirty`.
|
||||||
- [x] T022 Run targeted tests (e.g. `./vendor/bin/sail artisan test --filter=RestoreRunWizard` once tests exist).
|
- [x] T022 Run targeted tests (e.g. `./vendor/bin/sail artisan test --filter=RestoreRunWizard` once tests exist).
|
||||||
|
|
||||||
|
|||||||
@ -168,6 +168,7 @@
|
|||||||
->fillForm([
|
->fillForm([
|
||||||
'is_dry_run' => true,
|
'is_dry_run' => true,
|
||||||
])
|
])
|
||||||
|
->callFormComponentAction('preview_diffs', 'run_restore_preview')
|
||||||
->goToNextWizardStep()
|
->goToNextWizardStep()
|
||||||
->call('create')
|
->call('create')
|
||||||
->assertHasNoFormErrors();
|
->assertHasNoFormErrors();
|
||||||
|
|||||||
132
tests/Feature/RestorePreviewDiffWizardTest.php
Normal file
132
tests/Feature/RestorePreviewDiffWizardTest.php
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
|
||||||
|
use App\Models\BackupItem;
|
||||||
|
use App\Models\BackupSet;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
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 wizard generates a normalized preview diff summary and persists it', function () {
|
||||||
|
$tenant = Tenant::create([
|
||||||
|
'tenant_id' => 'tenant-1',
|
||||||
|
'name' => 'Tenant One',
|
||||||
|
'metadata' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
|
||||||
|
$policy = Policy::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'external_id' => 'policy-1',
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'display_name' => 'Device Config Policy',
|
||||||
|
'platform' => 'windows',
|
||||||
|
]);
|
||||||
|
|
||||||
|
PolicyVersion::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'version_number' => 1,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'captured_at' => now()->subDay(),
|
||||||
|
'snapshot' => [
|
||||||
|
'foo' => 'current',
|
||||||
|
],
|
||||||
|
'metadata' => [],
|
||||||
|
'assignments' => [],
|
||||||
|
'scope_tags' => [
|
||||||
|
'ids' => ['tag-2'],
|
||||||
|
'names' => ['Tag Two'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$backupSet = BackupSet::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'name' => 'Backup',
|
||||||
|
'status' => 'completed',
|
||||||
|
'item_count' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
BackupItem::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'policy_identifier' => $policy->external_id,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'captured_at' => now(),
|
||||||
|
'payload' => [
|
||||||
|
'foo' => 'backup',
|
||||||
|
],
|
||||||
|
'assignments' => [[
|
||||||
|
'target' => [
|
||||||
|
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||||
|
'groupId' => 'group-1',
|
||||||
|
],
|
||||||
|
'intent' => 'apply',
|
||||||
|
]],
|
||||||
|
'metadata' => [
|
||||||
|
'scope_tag_ids' => ['tag-1'],
|
||||||
|
'scope_tag_names' => ['Tag One'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$component = Livewire::test(CreateRestoreRun::class)
|
||||||
|
->fillForm([
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
])
|
||||||
|
->goToNextWizardStep()
|
||||||
|
->goToNextWizardStep()
|
||||||
|
->goToNextWizardStep()
|
||||||
|
->callFormComponentAction('preview_diffs', 'run_restore_preview');
|
||||||
|
|
||||||
|
$summary = $component->get('data.preview_summary');
|
||||||
|
$diffs = $component->get('data.preview_diffs');
|
||||||
|
|
||||||
|
expect($summary)->toBeArray();
|
||||||
|
expect($summary['policies_total'] ?? null)->toBe(1);
|
||||||
|
expect($summary['policies_changed'] ?? null)->toBe(1);
|
||||||
|
expect($summary['assignments_changed'] ?? null)->toBe(1);
|
||||||
|
expect($summary['scope_tags_changed'] ?? null)->toBe(1);
|
||||||
|
|
||||||
|
expect($diffs)->toBeArray();
|
||||||
|
expect($diffs)->not->toBeEmpty();
|
||||||
|
|
||||||
|
$first = $diffs[0] ?? [];
|
||||||
|
expect($first)->toBeArray();
|
||||||
|
expect($first['action'] ?? null)->toBe('update');
|
||||||
|
expect($first['assignments_changed'] ?? null)->toBeTrue();
|
||||||
|
expect($first['scope_tags_changed'] ?? null)->toBeTrue();
|
||||||
|
expect($first['diff']['summary']['changed'] ?? null)->toBe(1);
|
||||||
|
|
||||||
|
$component
|
||||||
|
->goToNextWizardStep()
|
||||||
|
->call('create')
|
||||||
|
->assertHasNoFormErrors();
|
||||||
|
|
||||||
|
$run = RestoreRun::query()->latest('id')->first();
|
||||||
|
|
||||||
|
expect($run)->not->toBeNull();
|
||||||
|
expect($run->metadata)->toHaveKeys([
|
||||||
|
'preview_summary',
|
||||||
|
'preview_diffs',
|
||||||
|
'preview_ran_at',
|
||||||
|
]);
|
||||||
|
expect($run->metadata['preview_summary']['policies_changed'] ?? null)->toBe(1);
|
||||||
|
});
|
||||||
@ -114,6 +114,7 @@
|
|||||||
|
|
||||||
$component
|
$component
|
||||||
->goToNextWizardStep()
|
->goToNextWizardStep()
|
||||||
|
->callFormComponentAction('preview_diffs', 'run_restore_preview')
|
||||||
->goToNextWizardStep()
|
->goToNextWizardStep()
|
||||||
->call('create')
|
->call('create')
|
||||||
->assertHasNoFormErrors();
|
->assertHasNoFormErrors();
|
||||||
|
|||||||
@ -65,6 +65,7 @@
|
|||||||
->fillForm([
|
->fillForm([
|
||||||
'is_dry_run' => true,
|
'is_dry_run' => true,
|
||||||
])
|
])
|
||||||
|
->callFormComponentAction('preview_diffs', 'run_restore_preview')
|
||||||
->goToNextWizardStep()
|
->goToNextWizardStep()
|
||||||
->call('create')
|
->call('create')
|
||||||
->assertHasNoFormErrors();
|
->assertHasNoFormErrors();
|
||||||
@ -127,6 +128,7 @@
|
|||||||
->goToNextWizardStep()
|
->goToNextWizardStep()
|
||||||
->goToNextWizardStep()
|
->goToNextWizardStep()
|
||||||
->set('data.is_dry_run', false)
|
->set('data.is_dry_run', false)
|
||||||
|
->callFormComponentAction('preview_diffs', 'run_restore_preview')
|
||||||
->goToNextWizardStep()
|
->goToNextWizardStep()
|
||||||
->call('create')
|
->call('create')
|
||||||
->assertHasNoFormErrors();
|
->assertHasNoFormErrors();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user