merge: agent session work
This commit is contained in:
commit
a43fef535b
@ -13,6 +13,7 @@
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\GroupResolver;
|
||||
use App\Services\Intune\RestoreDiffGenerator;
|
||||
use App\Services\Intune\RestoreRiskChecker;
|
||||
use App\Services\Intune\RestoreService;
|
||||
use BackedEnum;
|
||||
@ -180,6 +181,9 @@ public static function getWizardSteps(): array
|
||||
$set('check_summary', null);
|
||||
$set('check_results', []);
|
||||
$set('checks_ran_at', null);
|
||||
$set('preview_summary', null);
|
||||
$set('preview_diffs', []);
|
||||
$set('preview_ran_at', null);
|
||||
})
|
||||
->required(),
|
||||
]),
|
||||
@ -199,6 +203,9 @@ public static function getWizardSteps(): array
|
||||
$set('check_summary', null);
|
||||
$set('check_results', []);
|
||||
$set('checks_ran_at', null);
|
||||
$set('preview_summary', null);
|
||||
$set('preview_diffs', []);
|
||||
$set('preview_ran_at', null);
|
||||
|
||||
if ($state === 'all') {
|
||||
$set('backup_item_ids', null);
|
||||
@ -223,6 +230,9 @@ public static function getWizardSteps(): array
|
||||
$set('check_summary', null);
|
||||
$set('check_results', []);
|
||||
$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')
|
||||
->required(fn (Get $get): bool => $get('scope_mode') === 'selected')
|
||||
@ -292,6 +302,9 @@ public static function getWizardSteps(): array
|
||||
$set('check_summary', null);
|
||||
$set('check_results', []);
|
||||
$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.');
|
||||
}, $unresolved);
|
||||
@ -414,16 +427,99 @@ public static function getWizardSteps(): array
|
||||
->helperText('Run checks after defining scope and mapping missing groups.'),
|
||||
]),
|
||||
Step::make('Preview')
|
||||
->description('Dry-run preview (Phase 5)')
|
||||
->description('Dry-run preview')
|
||||
->schema([
|
||||
Forms\Components\Toggle::make('is_dry_run')
|
||||
->label('Preview only (dry-run)')
|
||||
->default(true)
|
||||
->disabled()
|
||||
->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')
|
||||
->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')
|
||||
->description('Explicit confirmations (Phase 6)')
|
||||
@ -976,8 +1072,18 @@ public static function createRestoreRun(array $data): RestoreRun
|
||||
$checkSummary = $data['check_summary'] ?? null;
|
||||
$checkResults = $data['check_results'] ?? 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 ?? [];
|
||||
|
||||
if (is_array($checkSummary)) {
|
||||
@ -992,6 +1098,18 @@ public static function createRestoreRun(array $data): RestoreRun
|
||||
$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([
|
||||
'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.
|
||||
|
||||
## Phase 5 — Preview (Diff)
|
||||
- [ ] T013 Implement `RestoreDiffGenerator` using `PolicyNormalizer` + `VersionDiff`.
|
||||
- [ ] T014 Persist preview summary (and per-item diffs with safe limits) and require preview completion before execute.
|
||||
- [x] T013 Implement `RestoreDiffGenerator` using `PolicyNormalizer` + `VersionDiff`.
|
||||
- [x] T014 Persist preview summary (and per-item diffs with safe limits) and require preview completion before execute.
|
||||
|
||||
## Phase 6 — Confirm & Execute
|
||||
- [ ] T015 Implement Step 5 confirmations (ack checkbox + tenant hard-confirm).
|
||||
@ -36,8 +36,8 @@ ## Phase 6 — Confirm & Execute
|
||||
|
||||
## Phase 7 — Tests + Formatting
|
||||
- [ ] T018 Add Pest tests for wizard gating rules and status transitions.
|
||||
- [ ] T019 Add Pest tests for safety checks persistence and blocking behavior.
|
||||
- [ ] T020 Add Pest tests for preview summary generation.
|
||||
- [x] T019 Add Pest tests for safety checks persistence and blocking behavior.
|
||||
- [x] T020 Add Pest tests for preview summary generation.
|
||||
- [x] T021 Run `./vendor/bin/pint --dirty`.
|
||||
- [x] T022 Run targeted tests (e.g. `./vendor/bin/sail artisan test --filter=RestoreRunWizard` once tests exist).
|
||||
|
||||
|
||||
@ -168,6 +168,7 @@
|
||||
->fillForm([
|
||||
'is_dry_run' => true,
|
||||
])
|
||||
->callFormComponentAction('preview_diffs', 'run_restore_preview')
|
||||
->goToNextWizardStep()
|
||||
->call('create')
|
||||
->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
|
||||
->goToNextWizardStep()
|
||||
->callFormComponentAction('preview_diffs', 'run_restore_preview')
|
||||
->goToNextWizardStep()
|
||||
->call('create')
|
||||
->assertHasNoFormErrors();
|
||||
|
||||
@ -65,6 +65,7 @@
|
||||
->fillForm([
|
||||
'is_dry_run' => true,
|
||||
])
|
||||
->callFormComponentAction('preview_diffs', 'run_restore_preview')
|
||||
->goToNextWizardStep()
|
||||
->call('create')
|
||||
->assertHasNoFormErrors();
|
||||
@ -127,6 +128,7 @@
|
||||
->goToNextWizardStep()
|
||||
->goToNextWizardStep()
|
||||
->set('data.is_dry_run', false)
|
||||
->callFormComponentAction('preview_diffs', 'run_restore_preview')
|
||||
->goToNextWizardStep()
|
||||
->call('create')
|
||||
->assertHasNoFormErrors();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user