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(), ]) ->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 ' Large payload ('.number_format($state / 1024, 0).' KB) - May impact performance '; } 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 ' Large payload ('.number_format($state / 1024, 0).' KB) - May impact performance '; } 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) { $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(fn (Policy $record) => $record->ignored_at === null) ->action(function (Policy $record) { $tenant = Tenant::current(); $user = auth()->user(); $service = app(BulkOperationService::class); $run = $service->createRun($tenant, $user, 'policy', 'sync', [$record->id], 1); BulkPolicySyncJob::dispatchSync($run->id); }), 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) { $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 { $visibilityFilterState = $livewire->getTableFilterState('visibility') ?? []; $value = $visibilityFilterState['value'] ?? null; return $value === 'ignored'; }) ->action(function (Collection $records) { $tenant = Tenant::current(); $user = auth()->user(); $count = $records->count(); $ids = $records->pluck('id')->toArray(); $service = app(BulkOperationService::class); $run = $service->createRun($tenant, $user, 'policy', 'sync', $ids, $count); if ($count >= 20) { Notification::make() ->title('Bulk sync started') ->body("Syncing {$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(); BulkPolicySyncJob::dispatch($run->id); } else { BulkPolicySyncJob::dispatchSync($run->id); } }) ->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 []; } /** * @return array */ 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>} $normalized * @return array{normalized: array, general: ?array} */ 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|array */ 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} */ 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 */ private static function settingsTabState(Policy $record): array { $normalized = static::normalizedPolicyState($record); $rows = $normalized['settings_table']['rows'] ?? []; $hasSettingsTable = is_array($rows) && $rows !== []; if ($record->policy_type === 'settingsCatalogPolicy' && $hasSettingsTable) { $split = static::splitGeneralBlock($normalized); return $split['normalized']; } return $normalized; } }