TenantAtlas/app/Filament/Resources/PolicyResource.php
ahmido bd6df1f343 055-ops-ux-rollout (#64)
Kurzbeschreibung

Implementiert Feature 055 — Ops‑UX Constitution Rollout v1.3.0.
Behebt: globales BulkOperationProgress-Widget benötigt keinen manuellen Refresh mehr; ETA/Elapsed aktualisieren korrekt; Widget verschwindet automatisch.
Verbesserungen: zuverlässiges polling (Alpine factory + Livewire fallback), sofortiger Enqueue‑Signal-Dispatch, Failure‑Message‑Sanitization, neue Guard‑ und Regressionstests, Specs/Tasks aktualisiert.
Was geändert wurde (Auszug)

InventoryLanding.php
bulk-operation-progress.blade.php
OperationUxPresenter.php
SyncRestoreRunToOperationRun.php
PolicyResource.php
PolicyVersionResource.php
RestoreRunResource.php
tests/Feature/OpsUx/* (PollerRegistration, TerminalNotificationFailureMessageTest, CanonicalViewRunLinksTest, OperationCatalogCoverageTest, UnknownOperationTypeLabelTest)
InventorySyncButtonTest.php
tasks.md
Tests

Neue Tests hinzugefügt; php artisan test --group=ops-ux lokal grün (alle relevanten Tests laufen).
How to verify manually

Auf Branch wechseln: 055-ops-ux-rollout
In Filament: Inventory → Sync (oder relevante Bulk‑Aktion) auslösen.
Beobachten: Progress‑Widget erscheint sofort, ETA/Elapsed aktualisiert, Widget verschwindet nach Fertigstellung ohne Browser‑Refresh.
Optional: ./vendor/bin/sail exec app php artisan test --filter=OpsUx oder php artisan test --group=ops-ux
Besonderheiten / Hinweise

Einzelne, synchrone Policy‑Actions (ignore/restore/PolicyVersion single archive/restore/forceDelete) sind absichtlich inline und erzeugen kein OperationRun. Bulk‑Aktionen und restore.execute werden als Runs modelliert. Wenn gewünscht, kann ich die inline‑Actions auf OperationRunService umstellen, damit sie in Monitoring → Operations sichtbar werden.
Remote: Branch ist bereits gepusht (origin/055-ops-ux-rollout). PR kann in Gitea erstellt werden.
Links

Specs & tasks: tasks.md
Monitoring page: Operations.php

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #64
2026-01-18 14:50:15 +00:00

922 lines
41 KiB
PHP

<?php
namespace App\Filament\Resources;
use App\Filament\Resources\PolicyResource\Pages;
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
use App\Jobs\BulkPolicyDeleteJob;
use App\Jobs\BulkPolicyExportJob;
use App\Jobs\BulkPolicyUnignoreJob;
use App\Jobs\SyncPoliciesJob;
use App\Models\Policy;
use App\Models\Tenant;
use App\Models\User;
use App\Services\BulkOperationService;
use App\Services\Intune\PolicyNormalizer;
use App\Services\OperationRunService;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use BackedEnum;
use Filament\Actions;
use Filament\Actions\ActionGroup;
use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
use Filament\Forms;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use UnitEnum;
class PolicyResource extends Resource
{
protected static ?string $model = Policy::class;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
public static function form(Schema $schema): Schema
{
return $schema;
}
public static function infolist(Schema $schema): Schema
{
return $schema
->schema([
Section::make('Policy Details')
->schema([
TextEntry::make('display_name')->label('Policy'),
TextEntry::make('policy_type')->label('Type'),
TextEntry::make('platform'),
TextEntry::make('external_id')->label('External ID'),
TextEntry::make('last_synced_at')->dateTime()->label('Last synced'),
TextEntry::make('created_at')->since(),
TextEntry::make('latest_snapshot_mode')
->label('Snapshot')
->badge()
->color(fn (Policy $record): string => (static::latestVersionMetadata($record)['source'] ?? null) === 'metadata_only' ? 'warning' : 'success')
->state(fn (Policy $record): string => (static::latestVersionMetadata($record)['source'] ?? null) === 'metadata_only' ? 'metadata only' : 'full')
->helperText(function (Policy $record): ?string {
$meta = static::latestVersionMetadata($record);
if (($meta['source'] ?? null) !== 'metadata_only') {
return null;
}
$status = $meta['original_status'] ?? null;
return sprintf(
'Graph returned %s for this policy type. Only local metadata was saved; settings and restore are unavailable until Graph works again.',
$status ?? 'an error'
);
})
->visible(fn (Policy $record) => $record->versions()->exists()),
])
->columns(2)
->columnSpanFull(),
// Tabbed content (General / Settings / JSON)
Tabs::make('policy_content')
->activeTab(1)
->persistTabInQueryString()
->tabs([
Tab::make('General')
->id('general')
->schema([
ViewEntry::make('policy_general')
->label('')
->view('filament.infolists.entries.policy-general')
->state(function (Policy $record) {
return static::generalOverviewState($record);
}),
])
->visible(fn (Policy $record) => $record->versions()->exists()),
Tab::make('Settings')
->id('settings')
->schema([
ViewEntry::make('settings_catalog')
->label('')
->view('filament.infolists.entries.normalized-settings')
->state(function (Policy $record) {
return static::settingsTabState($record);
})
->visible(fn (Policy $record) => static::hasSettingsTable($record) &&
$record->versions()->exists()
),
ViewEntry::make('settings_standard')
->label('')
->view('filament.infolists.entries.policy-settings-standard')
->state(function (Policy $record) {
return static::settingsTabState($record);
})
->visible(fn (Policy $record) => ! static::hasSettingsTable($record) &&
$record->versions()->exists()
),
TextEntry::make('no_settings_available')
->label('Settings')
->state('No policy snapshot available yet.')
->helperText('This policy has been inventoried but no configuration snapshot has been captured yet.')
->visible(fn (Policy $record) => ! $record->versions()->exists()),
]),
Tab::make('JSON')
->id('json')
->schema([
ViewEntry::make('snapshot_json')
->view('filament.infolists.entries.snapshot-json')
->state(fn (Policy $record) => static::latestSnapshot($record))
->columnSpanFull(),
TextEntry::make('snapshot_size')
->label('Payload Size')
->state(fn (Policy $record) => strlen(json_encode(static::latestSnapshot($record) ?: [])))
->formatStateUsing(function ($state) {
if ($state > 512000) {
return '<span class="inline-flex items-center gap-1 text-warning-600 dark:text-warning-400 font-semibold">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>
Large payload ('.number_format($state / 1024, 0).' KB) - May impact performance
</span>';
}
return number_format($state / 1024, 1).' KB';
})
->html()
->visible(fn (Policy $record) => strlen(json_encode(static::latestSnapshot($record) ?: [])) > 512000),
])
->visible(fn (Policy $record) => $record->versions()->exists()),
])
->columnSpanFull()
->visible(fn (Policy $record) => static::usesTabbedLayout($record)),
// Legacy layout (kept for fallback if tabs are disabled)
Section::make('Settings')
->schema([
ViewEntry::make('settings')
->label('')
->view('filament.infolists.entries.normalized-settings')
->state(function (Policy $record) {
$normalized = app(PolicyNormalizer::class)->normalize(
static::latestSnapshot($record),
$record->policy_type ?? '',
$record->platform
);
$normalized['context'] = 'policy';
$normalized['record_id'] = (string) $record->getKey();
return $normalized;
}),
])
->columnSpanFull()
->visible(function (Policy $record) {
return ! static::usesTabbedLayout($record);
}),
Section::make('Policy Snapshot (JSON)')
->schema([
ViewEntry::make('snapshot_json')
->view('filament.infolists.entries.snapshot-json')
->state(fn (Policy $record) => static::latestSnapshot($record))
->columnSpanFull(),
TextEntry::make('snapshot_size')
->label('Payload Size')
->state(fn (Policy $record) => strlen(json_encode(static::latestSnapshot($record) ?: [])))
->formatStateUsing(function ($state) {
if ($state > 512000) {
return '<span class="inline-flex items-center gap-1 text-warning-600 dark:text-warning-400 font-semibold">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>
Large payload ('.number_format($state / 1024, 0).' KB) - May impact performance
</span>';
}
return number_format($state / 1024, 1).' KB';
})
->html()
->visible(fn (Policy $record) => strlen(json_encode(static::latestSnapshot($record) ?: [])) > 512000),
])
->collapsible()
->collapsed(fn (Policy $record) => strlen(json_encode(static::latestSnapshot($record) ?: [])) > 512000)
->description('Raw JSON configuration from Microsoft Graph API')
->columnSpanFull()
->visible(function (Policy $record) {
return ! static::usesTabbedLayout($record);
}),
]);
}
public static function table(Table $table): Table
{
return $table
->modifyQueryUsing(function (Builder $query) {
// Quick-Workaround: Hide policies not synced in last 7 days
// Full solution in Feature 005: Policy Lifecycle Management (soft delete)
$query->where('last_synced_at', '>', now()->subDays(7));
})
->columns([
Tables\Columns\TextColumn::make('display_name')
->label('Policy')
->searchable(),
Tables\Columns\TextColumn::make('policy_type')
->label('Type')
->badge()
->formatStateUsing(fn (?string $state, Policy $record) => static::typeMeta($record->policy_type)['label'] ?? $state),
Tables\Columns\TextColumn::make('category')
->label('Category')
->badge()
->state(fn (Policy $record) => static::typeMeta($record->policy_type)['category'] ?? 'Unknown'),
Tables\Columns\TextColumn::make('restore_mode')
->label('Restore')
->badge()
->state(fn (Policy $record) => static::typeMeta($record->policy_type)['restore'] ?? 'enabled'),
Tables\Columns\TextColumn::make('platform')
->badge()
->sortable(),
Tables\Columns\TextColumn::make('settings_status')
->label('Settings')
->badge()
->state(function (Policy $record) {
$latest = $record->versions->first();
$snapshot = $latest?->snapshot ?? [];
$hasSettings = is_array($snapshot) && ! empty($snapshot['settings']);
return $hasSettings ? 'Available' : 'Missing';
})
->color(function (Policy $record) {
$latest = $record->versions->first();
$snapshot = $latest?->snapshot ?? [];
$hasSettings = is_array($snapshot) && ! empty($snapshot['settings']);
return $hasSettings ? 'success' : 'gray';
}),
Tables\Columns\TextColumn::make('external_id')
->label('External ID')
->copyable()
->limit(32),
Tables\Columns\TextColumn::make('last_synced_at')
->label('Last synced')
->dateTime()
->sortable(),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->since()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\Filters\SelectFilter::make('visibility')
->label('Visibility')
->options([
'active' => 'Active',
'ignored' => 'Ignored',
])
->default('active')
->query(function (Builder $query, array $data) {
$value = $data['value'] ?? null;
if (blank($value)) {
return;
}
if ($value === 'active') {
$query->whereNull('ignored_at');
return;
}
if ($value === 'ignored') {
$query->whereNotNull('ignored_at');
}
}),
Tables\Filters\SelectFilter::make('policy_type')
->options(function () {
return collect(config('tenantpilot.supported_policy_types', []))
->pluck('label', 'type')
->map(fn ($label, $type) => $label ?? $type)
->all();
}),
Tables\Filters\SelectFilter::make('category')
->options(function () {
return collect(config('tenantpilot.supported_policy_types', []))
->pluck('category', 'category')
->filter()
->unique()
->sort()
->all();
})
->query(function (Builder $query, array $data) {
$category = $data['value'] ?? null;
if (! $category) {
return;
}
$types = collect(config('tenantpilot.supported_policy_types', []))
->where('category', $category)
->pluck('type')
->all();
$query->whereIn('policy_type', $types);
}),
Tables\Filters\SelectFilter::make('platform')
->options(fn () => Policy::query()
->distinct()
->pluck('platform', 'platform')
->filter()
->reject(fn ($platform) => is_string($platform) && strtolower($platform) === 'all')
->all()),
])
->actions([
Actions\ViewAction::make(),
ActionGroup::make([
Actions\Action::make('ignore')
->label('Ignore')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->visible(fn (Policy $record) => $record->ignored_at === null)
->action(function (Policy $record, HasTable $livewire) {
$record->ignore();
Notification::make()
->title('Policy ignored')
->success()
->send();
}),
Actions\Action::make('restore')
->label('Restore')
->icon('heroicon-o-arrow-uturn-left')
->color('success')
->requiresConfirmation()
->visible(fn (Policy $record) => $record->ignored_at !== null)
->action(function (Policy $record) {
$record->unignore();
Notification::make()
->title('Policy restored')
->success()
->send();
}),
Actions\Action::make('sync')
->label('Sync')
->icon('heroicon-o-arrow-path')
->color('primary')
->requiresConfirmation()
->visible(function (Policy $record): bool {
if ($record->ignored_at !== null) {
return false;
}
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$tenant = Tenant::current();
return $user->canSyncTenant($tenant);
})
->action(function (Policy $record) {
$tenant = Tenant::current();
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
if (! $user->canAccessTenant($tenant) || ! $user->canSyncTenant($tenant)) {
abort(403);
}
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
tenant: $tenant,
type: 'policy.sync_one',
inputs: [
'scope' => 'one',
'policy_id' => (int) $record->getKey(),
],
initiator: $user
);
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
Notification::make()
->title('Policy sync already active')
->body('This operation is already queued or running.')
->warning()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
return;
}
SyncPoliciesJob::dispatch(
tenantId: (int) $tenant->getKey(),
policyIds: [(int) $record->getKey()],
operationRun: $opRun
);
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
}),
Actions\Action::make('export')
->label('Export to Backup')
->icon('heroicon-o-archive-box-arrow-down')
->visible(fn (Policy $record) => $record->ignored_at === null)
->form([
Forms\Components\TextInput::make('backup_name')
->label('Backup Name')
->required()
->default(fn () => 'Backup '.now()->toDateTimeString()),
])
->action(function (Policy $record, array $data) {
$tenant = Tenant::current();
$user = auth()->user();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy', 'export', [$record->id], 1);
BulkPolicyExportJob::dispatchSync($run->id, $data['backup_name']);
}),
])->icon('heroicon-o-ellipsis-vertical'),
])
->bulkActions([
BulkActionGroup::make([
BulkAction::make('bulk_delete')
->label('Ignore Policies')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->hidden(function (HasTable $livewire): bool {
$visibilityFilterState = $livewire->getTableFilterState('visibility') ?? [];
$value = $visibilityFilterState['value'] ?? null;
return $value === 'ignored';
})
->form(function (Collection $records) {
if ($records->count() >= 20) {
return [
Forms\Components\TextInput::make('confirmation')
->label('Type DELETE to confirm')
->required()
->in(['DELETE'])
->validationMessages([
'in' => 'Please type DELETE to confirm.',
]),
];
}
return [];
})
->action(function (Collection $records, array $data) {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy', 'delete', $ids, $count);
if ($count >= 20) {
Notification::make()
->title('Bulk delete started')
->body("Deleting {$count} policies in the background. Check the progress bar in the bottom right corner.")
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->info()
->duration(8000)
->sendToDatabase($user)
->send();
BulkPolicyDeleteJob::dispatch($run->id);
} else {
BulkPolicyDeleteJob::dispatchSync($run->id);
}
})
->deselectRecordsAfterCompletion(),
BulkAction::make('bulk_restore')
->label('Restore Policies')
->icon('heroicon-o-arrow-uturn-left')
->color('success')
->requiresConfirmation()
->hidden(function (HasTable $livewire): bool {
$visibilityFilterState = $livewire->getTableFilterState('visibility') ?? [];
$value = $visibilityFilterState['value'] ?? null;
return ! in_array($value, [null, 'ignored'], true);
})
->action(function (Collection $records, HasTable $livewire) {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy', 'unignore', $ids, $count);
if ($count >= 20) {
Notification::make()
->title('Bulk restore started')
->body("Restoring {$count} policies in the background. Check the progress bar in the bottom right corner.")
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->info()
->duration(8000)
->sendToDatabase($user)
->send();
BulkPolicyUnignoreJob::dispatch($run->id);
} else {
BulkPolicyUnignoreJob::dispatchSync($run->id);
}
})
->deselectRecordsAfterCompletion(),
BulkAction::make('bulk_sync')
->label('Sync Policies')
->icon('heroicon-o-arrow-path')
->color('primary')
->requiresConfirmation()
->hidden(function (HasTable $livewire): bool {
$user = auth()->user();
if (! $user instanceof User) {
return true;
}
$tenant = Tenant::current();
if (! $user->canAccessTenant($tenant) || ! $user->canSyncTenant($tenant)) {
return true;
}
$visibilityFilterState = $livewire->getTableFilterState('visibility') ?? [];
$value = $visibilityFilterState['value'] ?? null;
return $value === 'ignored';
})
->action(function (Collection $records) {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
if (! $user instanceof User) {
abort(403);
}
if (! $user->canAccessTenant($tenant) || ! $user->canSyncTenant($tenant)) {
abort(403);
}
$ids = $records
->pluck('id')
->map(static fn ($id): int => (int) $id)
->unique()
->sort()
->values()
->all();
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
tenant: $tenant,
type: 'policy.sync',
inputs: [
'scope' => 'subset',
'policy_ids' => $ids,
],
initiator: $user
);
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
Notification::make()
->title('Policy sync already active')
->body('This operation is already queued or running.')
->warning()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
return;
}
SyncPoliciesJob::dispatch(
tenantId: (int) $tenant->getKey(),
policyIds: $ids,
operationRun: $opRun
);
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
})
->deselectRecordsAfterCompletion(),
BulkAction::make('bulk_export')
->label('Export to Backup')
->icon('heroicon-o-archive-box-arrow-down')
->form([
Forms\Components\TextInput::make('backup_name')
->label('Backup Name')
->required()
->default(fn () => 'Backup '.now()->toDateTimeString()),
])
->action(function (Collection $records, array $data) {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy', 'export', $ids, $count);
if ($count >= 20) {
Notification::make()
->title('Bulk export started')
->body("Exporting {$count} policies to backup '{$data['backup_name']}' in the background. Check the progress bar in the bottom right corner.")
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->info()
->duration(8000)
->sendToDatabase($user)
->send();
BulkPolicyExportJob::dispatch($run->id, $data['backup_name']);
} else {
BulkPolicyExportJob::dispatchSync($run->id, $data['backup_name']);
}
})
->deselectRecordsAfterCompletion(),
]),
]);
}
public static function getEloquentQuery(): Builder
{
$tenantId = Tenant::current()->getKey();
return parent::getEloquentQuery()
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
->withCount('versions')
->with([
'versions' => fn ($query) => $query->orderByDesc('captured_at')->limit(1),
]);
}
public static function getRelations(): array
{
return [
VersionsRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListPolicies::route('/'),
'view' => Pages\ViewPolicy::route('/{record}'),
];
}
private static function latestSnapshot(Policy $record): array
{
$snapshot = $record->relationLoaded('versions')
? $record->versions->first()?->snapshot
: $record->versions()->orderByDesc('captured_at')->value('snapshot');
if (is_string($snapshot)) {
$decoded = json_decode($snapshot, true);
$snapshot = $decoded ?? [];
}
if (is_array($snapshot)) {
return $snapshot;
}
return [];
}
private static function latestVersionMetadata(Policy $record): array
{
$metadata = $record->relationLoaded('versions')
? $record->versions->first()?->metadata
: $record->versions()->orderByDesc('captured_at')->value('metadata');
if (is_string($metadata)) {
$decoded = json_decode($metadata, true);
$metadata = $decoded ?? [];
}
return is_array($metadata) ? $metadata : [];
}
/**
* @return array<string, mixed>
*/
private static function normalizedPolicyState(Policy $record): array
{
$cacheKey = 'tenantpilot.normalizedPolicyState.'.(string) $record->getKey();
$request = request();
if ($request->attributes->has($cacheKey)) {
$cached = $request->attributes->get($cacheKey);
if (is_array($cached)) {
return $cached;
}
}
$snapshot = static::latestSnapshot($record);
$normalized = app(PolicyNormalizer::class)->normalize(
$snapshot,
$record->policy_type,
$record->platform
);
$normalized['context'] = 'policy';
$normalized['record_id'] = (string) $record->getKey();
$normalized['policy_type'] = $record->policy_type;
$request->attributes->set($cacheKey, $normalized);
return $normalized;
}
/**
* @param array{settings?: array<int, array<string, mixed>>} $normalized
* @return array{normalized: array<string, mixed>, general: ?array<string, mixed>}
*/
private static function splitGeneralBlock(array $normalized): array
{
$general = null;
$filtered = [];
foreach ($normalized['settings'] ?? [] as $block) {
if (! is_array($block)) {
continue;
}
$title = $block['title'] ?? null;
if (is_string($title) && strtolower($title) === 'general') {
$general = $block;
continue;
}
$filtered[] = $block;
}
$normalized['settings'] = $filtered;
return [
'normalized' => $normalized,
'general' => $general,
];
}
/**
* @return array{label:?string,category:?string,restore:?string,risk:?string}|array<string,string>|array<string,mixed>
*/
private static function typeMeta(?string $type): array
{
if ($type === null) {
return [];
}
return collect(config('tenantpilot.supported_policy_types', []))
->firstWhere('type', $type) ?? [];
}
private static function usesTabbedLayout(Policy $record): bool
{
return true;
}
private static function hasSettingsTable(Policy $record): bool
{
$normalized = static::normalizedPolicyState($record);
$rows = $normalized['settings_table']['rows'] ?? [];
return is_array($rows) && $rows !== [];
}
/**
* @return array{entries: array<int, array{key: string, value: mixed}>}
*/
private static function generalOverviewState(Policy $record): array
{
$snapshot = static::latestSnapshot($record);
$entries = [];
$name = $snapshot['displayName'] ?? $snapshot['name'] ?? $record->display_name;
if (is_string($name) && $name !== '') {
$entries[] = ['key' => 'Name', 'value' => $name];
}
$platforms = $snapshot['platforms'] ?? $snapshot['platform'] ?? $record->platform;
if (is_string($platforms) && $platforms !== '') {
$entries[] = ['key' => 'Platforms', 'value' => $platforms];
} elseif (is_array($platforms) && $platforms !== []) {
$entries[] = ['key' => 'Platforms', 'value' => $platforms];
}
$technologies = $snapshot['technologies'] ?? null;
if (is_string($technologies) && $technologies !== '') {
$entries[] = ['key' => 'Technologies', 'value' => $technologies];
} elseif (is_array($technologies) && $technologies !== []) {
$entries[] = ['key' => 'Technologies', 'value' => $technologies];
}
if (array_key_exists('templateReference', $snapshot)) {
$entries[] = ['key' => 'Template Reference', 'value' => $snapshot['templateReference']];
}
$settingCount = $snapshot['settingCount']
?? $snapshot['settingsCount']
?? (isset($snapshot['settings']) && is_array($snapshot['settings']) ? count($snapshot['settings']) : null);
if (is_int($settingCount) || is_numeric($settingCount)) {
$entries[] = ['key' => 'Setting Count', 'value' => $settingCount];
}
$version = $snapshot['version'] ?? null;
if (is_string($version) && $version !== '') {
$entries[] = ['key' => 'Version', 'value' => $version];
} elseif (is_numeric($version)) {
$entries[] = ['key' => 'Version', 'value' => $version];
}
$lastModified = $snapshot['lastModifiedDateTime'] ?? null;
if (is_string($lastModified) && $lastModified !== '') {
$entries[] = ['key' => 'Last Modified', 'value' => $lastModified];
}
$createdAt = $snapshot['createdDateTime'] ?? null;
if (is_string($createdAt) && $createdAt !== '') {
$entries[] = ['key' => 'Created', 'value' => $createdAt];
}
$description = $snapshot['description'] ?? null;
if (is_string($description) && $description !== '') {
$entries[] = ['key' => 'Description', 'value' => $description];
}
return [
'entries' => $entries,
];
}
/**
* @return array<string, mixed>
*/
private static function settingsTabState(Policy $record): array
{
$normalized = static::normalizedPolicyState($record);
$rows = $normalized['settings_table']['rows'] ?? [];
$hasSettingsTable = is_array($rows) && $rows !== [];
if (in_array($record->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true) && $hasSettingsTable) {
$split = static::splitGeneralBlock($normalized);
return $split['normalized'];
}
return $normalized;
}
}