TenantAtlas/resources/views/filament/infolists/entries/restore-results.blade.php
ahmido bcf4996a1e feat/049-backup-restore-job-orchestration (#56)
Summary

This PR implements Spec 049 – Backup/Restore Job Orchestration: all critical Backup/Restore execution paths are job-only, idempotent, tenant-scoped, and observable via run records + DB notifications (Phase 1). The UI no longer performs heavy Graph work inside request/Filament actions for these flows.

Why

We want predictable UX and operations at MSP scale:
	•	no timeouts / long-running requests
	•	reproducible run state + per-item results
	•	safe error persistence (no secrets / no token leakage)
	•	strict tenant isolation + auditability for write paths

What changed

Foundational (Runs + Idempotency + Observability)
	•	Added a shared RunIdempotency helper (dedupe while queued/running).
	•	Added a read-only BulkOperationRuns surface (list + view) for status/progress.
	•	Added DB notifications for run status changes (with “View run” link).

US1 – Policy “Capture snapshot” is job-only
	•	Policy detail “Capture snapshot” now:
	•	creates/reuses a run (dedupe key: tenant + policy.capture_snapshot + policy DB id)
	•	dispatches a queued job
	•	returns immediately with notification + link to run detail
	•	Graph capture work moved fully into the job; request path stays Graph-free.

US3 – Restore runs orchestration is job-only + safe
	•	Live restore execution is queued and updates RestoreRun status/progress.
	•	Per-item outcomes are persisted deterministically (per internal DB record).
	•	Audit logging is written for live restore.
	•	Preview/dry-run is enforced as read-only (no writes).

Tenant isolation / authorization (non-negotiable)
	•	Run list/view/start are tenant-scoped and policy-guarded (cross-tenant access => 403, not 404).
	•	Explicit Pest tests cover cross-tenant denial and start authorization.

Tests / Verification
	•	./vendor/bin/pint --dirty
	•	Targeted suite (examples):
	•	policy capture snapshot queued + idempotency tests
	•	restore orchestration + audit logging + preview read-only tests
	•	run authorization / tenant isolation tests

Notes / Scope boundaries
	•	Phase 1 UX = DB notifications + run detail page. A global “progress widget” is tracked as Phase 2 and not required for merge.
	•	Resilience/backoff is tracked in tasks but can be iterated further after merge.

Review focus
	•	Dedupe behavior for queued/running runs (reuse vs create-new)
	•	Tenant scoping & policy gates for all run surfaces
	•	Restore safety: audit event + preview no-writes

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #56
2026-01-11 15:59:06 +00:00

382 lines
25 KiB
PHP

@php
$state = $getState() ?? [];
$isFoundationEntry = function ($item) {
return is_array($item) && array_key_exists('decision', $item) && array_key_exists('sourceId', $item);
};
if (is_array($state) && array_key_exists('items', $state)) {
$foundationItems = collect($state['foundations'] ?? [])->filter($isFoundationEntry);
$policyItems = collect($state['items'] ?? [])->values();
$results = $state;
} else {
$results = $state;
$foundationItems = collect($results)->filter($isFoundationEntry);
$policyItems = collect($results)->reject($isFoundationEntry);
}
@endphp
@if ($foundationItems->isEmpty() && $policyItems->isEmpty())
<p class="text-sm text-gray-600">No results recorded.</p>
@else
@php
$needsAttention = $policyItems->contains(function ($item) {
$status = $item['status'] ?? null;
return in_array($status, ['partial', 'manual_required'], true);
});
@endphp
<div class="space-y-4">
@if ($foundationItems->isNotEmpty())
<div class="space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500">Foundations</div>
@foreach ($foundationItems as $item)
@php
$decision = $item['decision'] ?? 'mapped_existing';
$decisionColor = match ($decision) {
'created' => 'text-green-700 bg-green-100 border-green-200',
'created_copy' => 'text-amber-900 bg-amber-100 border-amber-200',
'mapped_existing' => 'text-blue-700 bg-blue-100 border-blue-200',
'failed' => 'text-red-700 bg-red-100 border-red-200',
'skipped' => 'text-amber-900 bg-amber-50 border-amber-200',
default => 'text-gray-700 bg-gray-100 border-gray-200',
};
@endphp
<div class="rounded border border-gray-200 bg-white p-3 shadow-sm">
<div class="flex items-center justify-between text-sm text-gray-800">
<span class="font-semibold">{{ $item['sourceName'] ?? $item['sourceId'] ?? 'Foundation' }}</span>
<span class="rounded border px-2 py-0.5 text-xs font-semibold uppercase tracking-wide {{ $decisionColor }}">
{{ $decision }}
</span>
</div>
<div class="mt-1 text-xs text-gray-600">
{{ $item['type'] ?? 'foundation' }}
</div>
@if (! empty($item['targetName']))
<div class="mt-1 text-xs text-gray-600">
Target: {{ $item['targetName'] }}
</div>
@endif
@if (! empty($item['reason']))
<div class="mt-2 rounded border border-amber-200 bg-amber-50 px-2 py-1 text-xs text-amber-900">
{{ $item['reason'] }}
</div>
@endif
</div>
@endforeach
</div>
@endif
@if ($needsAttention)
<div class="rounded border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900">
Some settings could not be applied automatically. Review the per-setting details below.
</div>
@endif
@if ($policyItems->isNotEmpty())
<div class="space-y-3">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500">Policies</div>
@foreach ($policyItems as $item)
<div class="rounded border border-gray-200 bg-white p-3 shadow-sm">
<div class="flex items-center justify-between text-sm">
<div class="font-semibold text-gray-900">
{{ $item['policy_identifier'] ?? $item['policy_id'] ?? 'Policy' }}
<span class="ml-2 text-xs text-gray-500">{{ $item['policy_type'] ?? '' }}</span>
</div>
@php
$status = $item['status'] ?? 'unknown';
$restoreMode = $item['restore_mode'] ?? null;
$statusColor = match ($status) {
'applied' => 'text-green-700 bg-green-100 border-green-200',
'dry_run' => 'text-blue-700 bg-blue-100 border-blue-200',
'skipped' => 'text-amber-900 bg-amber-50 border-amber-200',
'partial' => 'text-amber-900 bg-amber-50 border-amber-200',
'manual_required' => 'text-amber-900 bg-amber-100 border-amber-200',
'failed' => 'text-red-700 bg-red-100 border-red-200',
default => 'text-gray-700 bg-gray-100 border-gray-200',
};
@endphp
<div class="flex items-center gap-2">
@if ($restoreMode === 'preview-only')
<span class="rounded border border-amber-200 bg-amber-50 px-2 py-0.5 text-xs font-semibold uppercase tracking-wide text-amber-900">
preview-only
</span>
@endif
<span class="rounded border px-2 py-0.5 text-xs font-semibold uppercase tracking-wide {{ $statusColor }}">
{{ $status }}
</span>
</div>
</div>
@php
$itemReason = $item['reason'] ?? null;
$itemGraphMessage = $item['graph_error_message'] ?? null;
if ($itemReason === 'preview_only') {
$itemReason = 'Preview-only policy type; execution skipped.';
}
@endphp
@if (! empty($itemReason) && ($itemGraphMessage === null || $itemGraphMessage !== $itemReason))
<div class="mt-2 text-sm text-gray-800">
{{ $itemReason }}
</div>
@endif
@if (! empty($item['assignment_summary']) && is_array($item['assignment_summary']))
@php
$summary = $item['assignment_summary'];
$assignmentOutcomes = $item['assignment_outcomes'] ?? [];
$assignmentIssues = collect($assignmentOutcomes)
->filter(fn ($outcome) => in_array($outcome['status'] ?? null, ['failed', 'skipped'], true))
->values();
@endphp
<div class="mt-2 text-xs text-gray-700">
Assignments: {{ (int) ($summary['success'] ?? 0) }} success
{{ (int) ($summary['failed'] ?? 0) }} failed
{{ (int) ($summary['skipped'] ?? 0) }} skipped
</div>
@if ($assignmentIssues->isNotEmpty())
<details class="mt-2 rounded border border-amber-200 bg-amber-50 px-2 py-1 text-xs text-amber-900">
<summary class="cursor-pointer font-semibold">Assignment details</summary>
<div class="mt-2 space-y-2">
@foreach ($assignmentIssues as $outcome)
@php
$outcomeStatus = $outcome['status'] ?? 'unknown';
$outcomeColor = match ($outcomeStatus) {
'failed' => 'text-red-700 bg-red-100 border-red-200',
'skipped' => 'text-amber-900 bg-amber-100 border-amber-200',
default => 'text-gray-700 bg-gray-100 border-gray-200',
};
$assignmentGroupId = $outcome['group_id']
?? ($outcome['assignment']['target']['groupId'] ?? null);
@endphp
<div class="rounded border border-amber-200 bg-white p-2">
<div class="flex items-center justify-between">
<div class="font-semibold text-gray-900">
Assignment {{ $assignmentGroupId ?? 'unknown group' }}
</div>
<span class="rounded border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide {{ $outcomeColor }}">
{{ $outcomeStatus }}
</span>
</div>
@if (! empty($outcome['mapped_group_id']))
<div class="mt-1 text-[11px] text-gray-800">
Mapped to: {{ $outcome['mapped_group_id'] }}
</div>
@endif
@php
$outcomeReason = $outcome['reason'] ?? null;
$outcomeGraphMessage = $outcome['graph_error_message'] ?? null;
@endphp
@if (! empty($outcomeReason) && ($outcomeGraphMessage === null || $outcomeGraphMessage !== $outcomeReason))
<div class="mt-1 text-[11px] text-gray-800">
{{ $outcomeReason }}
</div>
@endif
@if (! empty($outcome['graph_error_message']) || ! empty($outcome['graph_error_code']))
<div class="mt-1 text-[11px] text-amber-900">
<div>{{ $outcome['graph_error_message'] ?? 'Unknown error' }}</div>
@if (! empty($outcome['graph_error_code']))
<div class="mt-0.5 text-amber-800">Code: {{ $outcome['graph_error_code'] }}</div>
@endif
</div>
@endif
</div>
@endforeach
</div>
</details>
@endif
@endif
@if (! empty($item['compliance_action_summary']) && is_array($item['compliance_action_summary']))
@php
$summary = $item['compliance_action_summary'];
$complianceOutcomes = is_array($item['compliance_action_outcomes'] ?? null)
? $item['compliance_action_outcomes']
: [];
$complianceEntries = collect($complianceOutcomes)->values();
@endphp
<div class="mt-2 text-xs text-gray-700">
Compliance notifications: {{ (int) ($summary['mapped'] ?? 0) }} mapped
{{ (int) ($summary['skipped'] ?? 0) }} skipped
</div>
@if ($complianceEntries->isNotEmpty())
<details class="mt-2 rounded border border-amber-200 bg-amber-50 px-2 py-1 text-xs text-amber-900">
<summary class="cursor-pointer font-semibold">Compliance notification details</summary>
<div class="mt-2 space-y-2">
@foreach ($complianceEntries as $outcome)
@php
$outcomeStatus = $outcome['status'] ?? 'unknown';
$outcomeColor = match ($outcomeStatus) {
'mapped' => 'text-green-700 bg-green-100 border-green-200',
'skipped' => 'text-amber-900 bg-amber-100 border-amber-200',
default => 'text-gray-700 bg-gray-100 border-gray-200',
};
@endphp
<div class="rounded border border-amber-200 bg-white p-2">
<div class="flex items-center justify-between">
<div class="font-semibold text-gray-900">
Template {{ $outcome['template_id'] ?? 'unknown' }}
</div>
<span class="rounded border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide {{ $outcomeColor }}">
{{ $outcomeStatus }}
</span>
</div>
@if (! empty($outcome['rule_name']))
<div class="mt-1 text-[11px] text-gray-700">
Rule: {{ $outcome['rule_name'] }}
</div>
@endif
@if (! empty($outcome['mapped_template_id']))
<div class="mt-1 text-[11px] text-gray-700">
Mapped to: {{ $outcome['mapped_template_id'] }}
</div>
@endif
@if (! empty($outcome['reason']))
<div class="mt-1 text-[11px] text-gray-800">
{{ $outcome['reason'] }}
</div>
@endif
</div>
@endforeach
</div>
</details>
@endif
@endif
@if (! empty($item['created_policy_id']))
@php
$createdMode = $item['created_policy_mode'] ?? null;
$createdMessage = match ($createdMode) {
'metadata_only' => 'New policy created (metadata only). Apply settings manually.',
'created' => 'New policy created.',
default => 'New policy created (manual cleanup required).',
};
@endphp
<div class="mt-2 text-xs text-amber-800">
{{ $createdMessage }} ID: {{ $item['created_policy_id'] }}
</div>
@endif
@if (! empty($item['graph_error_message']) || ! empty($item['graph_error_code']))
<div class="mt-2 rounded border border-amber-200 bg-amber-50 px-2 py-1 text-xs text-amber-900">
<div class="font-semibold">Graph error</div>
<div>{{ $item['graph_error_message'] ?? 'Unknown error' }}</div>
@if (! empty($item['graph_error_code']))
<div class="mt-1 text-[11px] text-amber-800">Code: {{ $item['graph_error_code'] }}</div>
@endif
@if (! empty($item['graph_request_id']) || ! empty($item['graph_client_request_id']) || ! empty($item['graph_method']) || ! empty($item['graph_path']))
<details class="mt-1">
<summary class="cursor-pointer text-[11px] font-semibold text-amber-800">Details</summary>
<div class="mt-1 space-y-0.5 text-[11px] text-amber-800">
@if (! empty($item['graph_method']))
<div>method: {{ $item['graph_method'] }}</div>
@endif
@if (! empty($item['graph_path']))
<div>path: {{ $item['graph_path'] }}</div>
@endif
@if (! empty($item['graph_request_id']))
<div>request-id: {{ $item['graph_request_id'] }}</div>
@endif
@if (! empty($item['graph_client_request_id']))
<div>client-request-id: {{ $item['graph_client_request_id'] }}</div>
@endif
</div>
</details>
@endif
</div>
@endif
@if (! empty($item['settings_apply']) && is_array($item['settings_apply']))
@php
$apply = $item['settings_apply'];
$total = (int) ($apply['total'] ?? 0);
$applied = (int) ($apply['applied'] ?? 0);
$failed = (int) ($apply['failed'] ?? 0);
$manual = (int) ($apply['manual_required'] ?? 0);
$issues = $apply['issues'] ?? [];
@endphp
<div class="mt-2 text-xs text-gray-700">
Settings applied: {{ $applied }}/{{ $total }}
@if ($failed > 0 || $manual > 0)
{{ $failed }} failed {{ $manual }} manual
@endif
</div>
@if (! empty($issues))
<details class="mt-2 rounded border border-amber-200 bg-amber-50 px-2 py-1 text-xs text-amber-900">
<summary class="cursor-pointer font-semibold">Settings requiring attention</summary>
<div class="mt-2 space-y-2">
@foreach ($issues as $issue)
@php
$issueStatus = $issue['status'] ?? 'unknown';
$issueColor = match ($issueStatus) {
'failed' => 'text-red-700 bg-red-100 border-red-200',
'manual_required' => 'text-amber-900 bg-amber-100 border-amber-200',
default => 'text-gray-700 bg-gray-100 border-gray-200',
};
@endphp
<div class="rounded border border-amber-200 bg-white p-2">
<div class="flex items-center justify-between">
<div class="font-semibold text-gray-900">
Setting {{ $issue['setting_id'] ?? 'unknown' }}
</div>
<span class="rounded border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide {{ $issueColor }}">
{{ $issueStatus }}
</span>
</div>
@if (! empty($issue['reason']))
<div class="mt-1 text-[11px] text-gray-800">
{{ $issue['reason'] }}
</div>
@endif
@if (! empty($issue['graph_error_message']) || ! empty($issue['graph_error_code']))
<div class="mt-1 text-[11px] text-amber-900">
<div>{{ $issue['graph_error_message'] ?? 'Unknown error' }}</div>
@if (! empty($issue['graph_error_code']))
<div class="mt-0.5 text-amber-800">Code: {{ $issue['graph_error_code'] }}</div>
@endif
@if (! empty($issue['graph_request_id']) || ! empty($issue['graph_client_request_id']))
<div class="mt-0.5 space-y-0.5 text-amber-800">
@if (! empty($issue['graph_request_id']))
<div>request-id: {{ $issue['graph_request_id'] }}</div>
@endif
@if (! empty($issue['graph_client_request_id']))
<div>client-request-id: {{ $issue['graph_client_request_id'] }}</div>
@endif
</div>
@endif
</div>
@endif
</div>
@endforeach
</div>
</details>
@endif
@endif
@if (! empty($item['platform']))
<div class="mt-2 text-[11px] text-gray-500">
Platform: {{ $item['platform'] }}
</div>
@endif
</div>
@endforeach
</div>
@endif
</div>
@endif