Added `BaselineSubjectResolution` page and supporting logic to visualize missing identities, ambiguous matches, and skipped coverages as defined in Spec 384. Replaces legacy compare warnings with an actionable, deterministic UI surface. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #455
1072 lines
40 KiB
PHP
1072 lines
40 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Filament\Pages;
|
|
|
|
use App\Models\ManagedEnvironment;
|
|
use App\Models\OperationRun;
|
|
use App\Models\ProviderResourceBinding;
|
|
use App\Models\User;
|
|
use App\Models\Workspace;
|
|
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
|
|
use App\Services\Baselines\BaselineCompareService;
|
|
use App\Services\Baselines\BaselineSubjectResolutionQuery;
|
|
use App\Services\Resources\ProviderResourceBindingService;
|
|
use App\Support\Auth\Capabilities;
|
|
use App\Support\Filament\TablePaginationProfiles;
|
|
use App\Support\ManagedEnvironmentLinks;
|
|
use App\Support\OperationRunLinks;
|
|
use App\Support\OperationRunType;
|
|
use App\Support\OpsUx\OperationUxPresenter;
|
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
|
use App\Support\Rbac\UiEnforcement;
|
|
use App\Support\Resources\ResourceIdentity;
|
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
|
use BackedEnum;
|
|
use Filament\Actions\Action;
|
|
use Filament\Facades\Filament;
|
|
use Filament\Forms\Components\Select;
|
|
use Filament\Forms\Components\Textarea;
|
|
use Filament\Notifications\Notification;
|
|
use Filament\Pages\Page;
|
|
use Filament\Tables\Columns\TextColumn;
|
|
use Filament\Tables\Concerns\InteractsWithTable;
|
|
use Filament\Tables\Contracts\HasTable;
|
|
use Filament\Tables\Filters\SelectFilter;
|
|
use Filament\Tables\Table;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Pagination\LengthAwarePaginator;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\Gate;
|
|
use Illuminate\Support\Str;
|
|
use InvalidArgumentException;
|
|
use Livewire\Attributes\Locked;
|
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
|
|
class BaselineSubjectResolution extends Page implements HasTable
|
|
{
|
|
use InteractsWithTable;
|
|
|
|
private const array LEGACY_SCOPE_QUERY_KEYS = [
|
|
'environment_id',
|
|
'tenant',
|
|
'tenant_id',
|
|
'managed_environment_id',
|
|
'environment',
|
|
'tenant_scope',
|
|
'tableFilters',
|
|
];
|
|
|
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-puzzle-piece';
|
|
|
|
protected static ?string $navigationLabel = 'Baseline Subject Resolution';
|
|
|
|
protected static bool $shouldRegisterNavigation = false;
|
|
|
|
protected static ?string $title = 'Baseline Subject Resolution';
|
|
|
|
protected static ?string $slug = 'workspaces/{workspace}/environments/{environment}/baseline-subject-resolution';
|
|
|
|
protected string $view = 'filament.pages.baseline-subject-resolution';
|
|
|
|
#[Locked]
|
|
public ?int $scopedEnvironmentId = null;
|
|
|
|
public ?int $focusedOperationRunId = null;
|
|
|
|
/**
|
|
* @param array<mixed> $parameters
|
|
*/
|
|
public static function getUrl(array $parameters = [], bool $isAbsolute = true, ?string $panel = null, ?Model $tenant = null): string
|
|
{
|
|
$panelId = $panel ?? Filament::getCurrentOrDefaultPanel()?->getId() ?? 'admin';
|
|
|
|
if ($panelId !== 'admin') {
|
|
return parent::getUrl($parameters, $isAbsolute, $panelId, $tenant);
|
|
}
|
|
|
|
$environment = static::resolveAdminUrlEnvironment($parameters, $tenant);
|
|
|
|
if (! $environment instanceof ManagedEnvironment) {
|
|
return url('/admin');
|
|
}
|
|
|
|
$workspace = static::resolveAdminUrlWorkspace($environment, $parameters);
|
|
|
|
if (! $workspace instanceof Workspace && ! is_string($workspace) && ! is_int($workspace)) {
|
|
return url('/admin');
|
|
}
|
|
|
|
$parameters = static::withoutLegacyScopeQuery($parameters);
|
|
$parameters['environment'] = $environment;
|
|
$parameters['workspace'] = $workspace instanceof Workspace
|
|
? static::workspaceRouteKey($workspace)
|
|
: $workspace;
|
|
|
|
return parent::getUrl($parameters, $isAbsolute, $panelId, null);
|
|
}
|
|
|
|
public static function canAccess(): bool
|
|
{
|
|
$environment = static::resolveRouteOwnedEnvironment();
|
|
$user = auth()->user();
|
|
|
|
if (! $environment instanceof ManagedEnvironment || ! $user instanceof User) {
|
|
return false;
|
|
}
|
|
|
|
if (! static::routeWorkspaceMatchesEnvironment($environment)) {
|
|
return false;
|
|
}
|
|
|
|
return app(ManagedEnvironmentAccessScopeResolver::class)
|
|
->decision($user, $environment, Capabilities::WORKSPACE_BASELINES_VIEW)
|
|
->allowed();
|
|
}
|
|
|
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
{
|
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::CrudListAndView)
|
|
->withListRowPrimaryActionLimit(1)
|
|
->satisfy(ActionSurfaceSlot::ListHeader, 'The page header exposes only the delegated compare rerun action and neutral navigation back to compare.')
|
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Row decisions are constrained to one primary binding action plus confirmed secondary decision/revoke actions.')
|
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The table distinguishes no compare context from no decisions required.')
|
|
->exempt(ActionSurfaceSlot::DetailHeader, 'Spec 384 V1 does not add a separate detail route; subject context is shown in the row and confirmed action modals.')
|
|
->exempt(ActionSurfaceSlot::InspectAffordance, 'Rows expose compact candidate and decision context inline through the table and modal copy instead of a second detail route in v1.')
|
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Baseline subject decisions are high-impact and intentionally have no bulk action in v1.');
|
|
}
|
|
|
|
public function mount(ManagedEnvironment|string|null $environment = null): void
|
|
{
|
|
$tenant = static::resolveRouteOwnedEnvironment($environment);
|
|
$this->authorizeEnvironmentOrAbort($tenant, Capabilities::WORKSPACE_BASELINES_VIEW);
|
|
|
|
$this->scopedEnvironmentId = (int) $tenant->getKey();
|
|
$this->focusedOperationRunId = $this->scopedOperationRunIdFromQuery($tenant);
|
|
$this->heading = $tenant->getFilamentName();
|
|
$this->subheading = 'Baseline subject resolution';
|
|
|
|
$this->mountInteractsWithTable();
|
|
}
|
|
|
|
public function table(Table $table): Table
|
|
{
|
|
return $table
|
|
->queryStringIdentifier('baselineSubjectResolution')
|
|
->defaultSort('readiness_impact')
|
|
->defaultPaginationPageOption(25)
|
|
->paginated(TablePaginationProfiles::customPage())
|
|
->searchable()
|
|
->searchPlaceholder('Search subject, reason, provider, decision')
|
|
->records(function (
|
|
?string $sortColumn,
|
|
?string $sortDirection,
|
|
?string $search,
|
|
array $filters,
|
|
int $page,
|
|
int $recordsPerPage
|
|
): LengthAwarePaginator {
|
|
$rows = $this->filteredRows($filters, $search);
|
|
$rows = $this->sortRows($rows, $sortColumn, $sortDirection);
|
|
|
|
return $this->paginateRows($rows, $page, $recordsPerPage);
|
|
})
|
|
->filters([
|
|
SelectFilter::make('provider')
|
|
->label('Provider')
|
|
->options(fn (): array => $this->filterOptions('provider')),
|
|
SelectFilter::make('subject_class')
|
|
->label('Class')
|
|
->options(fn (): array => $this->filterOptions('subject_class')),
|
|
SelectFilter::make('resource_type')
|
|
->label('Resource type')
|
|
->options(fn (): array => $this->filterOptions('resource_type')),
|
|
SelectFilter::make('actionability')
|
|
->label('Actionability')
|
|
->options(fn (): array => $this->filterOptions('actionability')),
|
|
SelectFilter::make('readiness_impact')
|
|
->label('Readiness')
|
|
->options(fn (): array => $this->filterOptions('readiness_impact')),
|
|
SelectFilter::make('reason')
|
|
->label('Reason')
|
|
->options(fn (): array => $this->filterOptions('reason')),
|
|
SelectFilter::make('active_binding')
|
|
->label('Decision')
|
|
->options([
|
|
'yes' => 'Decision recorded',
|
|
'no' => 'No decision',
|
|
]),
|
|
SelectFilter::make('candidates')
|
|
->label('Candidates')
|
|
->options([
|
|
'yes' => 'Has candidates',
|
|
'no' => 'No candidates',
|
|
]),
|
|
])
|
|
->columns([
|
|
TextColumn::make('subject_label')
|
|
->label('Subject')
|
|
->description(fn (Model $record): string => (string) $record->getAttribute('resource_type_label'))
|
|
->searchable()
|
|
->sortable()
|
|
->wrap(),
|
|
TextColumn::make('reason_label')
|
|
->label('Problem')
|
|
->badge()
|
|
->color('warning')
|
|
->searchable()
|
|
->sortable()
|
|
->wrap(),
|
|
TextColumn::make('readiness_label')
|
|
->label('Readiness')
|
|
->badge()
|
|
->color(fn (Model $record): string => $this->readinessColor((string) $record->getAttribute('readiness_impact')))
|
|
->sortable()
|
|
->wrap(),
|
|
TextColumn::make('actionability_label')
|
|
->label('Actionability')
|
|
->badge()
|
|
->color('gray')
|
|
->sortable()
|
|
->wrap(),
|
|
TextColumn::make('candidate_count')
|
|
->label('Candidates')
|
|
->numeric()
|
|
->sortable(),
|
|
TextColumn::make('current_decision_label')
|
|
->label('Current decision')
|
|
->badge()
|
|
->color(fn (Model $record): string => $record->getAttribute('active_binding_id') === null ? 'gray' : 'success')
|
|
->sortable()
|
|
->wrap(),
|
|
TextColumn::make('source_operation_run_id')
|
|
->label('Source')
|
|
->formatStateUsing(fn (mixed $state): string => is_numeric($state) ? 'Operation #'.(int) $state : 'Latest compare')
|
|
->sortable(),
|
|
TextColumn::make('provider_label')
|
|
->label('Provider')
|
|
->toggleable(isToggledHiddenByDefault: true)
|
|
->sortable(),
|
|
TextColumn::make('subject_class_label')
|
|
->label('Class')
|
|
->toggleable(isToggledHiddenByDefault: true)
|
|
->sortable(),
|
|
TextColumn::make('trust_level')
|
|
->label('Trust')
|
|
->toggleable(isToggledHiddenByDefault: true)
|
|
->formatStateUsing(fn (?string $state): string => $state !== null ? Str::of($state)->replace('_', ' ')->headline()->toString() : 'Unknown'),
|
|
])
|
|
->actions([
|
|
$this->bindSubjectAction(),
|
|
$this->recordDecisionAction(),
|
|
$this->revokeDecisionAction(),
|
|
])
|
|
->bulkActions([])
|
|
->emptyStateHeading(fn (): string => $this->emptyStateHeading())
|
|
->emptyStateDescription(fn (): string => $this->emptyStateDescription())
|
|
->emptyStateActions([
|
|
$this->runCompareEmptyStateAction(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
protected function getViewData(): array
|
|
{
|
|
$tenant = $this->currentEnvironment();
|
|
$summary = $tenant instanceof ManagedEnvironment
|
|
? $this->query()->summary($tenant, $this->focusedOperationRunId)
|
|
: [
|
|
'has_run' => false,
|
|
'actionable_count' => 0,
|
|
'source_operation_run_id' => null,
|
|
'by_actionability' => [],
|
|
'by_readiness_impact' => [],
|
|
'by_reason' => [],
|
|
'legacy_payload_only' => false,
|
|
];
|
|
|
|
return [
|
|
'summary' => $summary,
|
|
'compareUrl' => $tenant instanceof ManagedEnvironment ? ManagedEnvironmentLinks::baselineCompareUrl($tenant) : null,
|
|
'sourceRunUrl' => $tenant instanceof ManagedEnvironment && is_numeric($summary['source_operation_run_id'] ?? null)
|
|
? OperationRunLinks::view((int) $summary['source_operation_run_id'], $tenant)
|
|
: null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<Action>
|
|
*/
|
|
protected function getHeaderActions(): array
|
|
{
|
|
$actions = [];
|
|
$tenant = $this->currentEnvironment();
|
|
|
|
if ($tenant instanceof ManagedEnvironment) {
|
|
$actions[] = Action::make('openBaselineCompare')
|
|
->label('Open baseline compare')
|
|
->icon('heroicon-o-arrow-left')
|
|
->color('gray')
|
|
->url(ManagedEnvironmentLinks::baselineCompareUrl($tenant));
|
|
}
|
|
|
|
$actions[] = $this->runCompareAction('runComparisonAgain');
|
|
|
|
return $actions;
|
|
}
|
|
|
|
public function currentEnvironment(): ?ManagedEnvironment
|
|
{
|
|
$tenant = $this->scopedEnvironmentId === null
|
|
? static::resolveRouteOwnedEnvironment()
|
|
: ManagedEnvironment::query()
|
|
->withTrashed()
|
|
->whereKey($this->scopedEnvironmentId)
|
|
->first();
|
|
|
|
if (! $tenant instanceof ManagedEnvironment) {
|
|
return null;
|
|
}
|
|
|
|
$this->authorizeEnvironmentOrAbort($tenant, Capabilities::WORKSPACE_BASELINES_VIEW);
|
|
|
|
return $tenant;
|
|
}
|
|
|
|
private function bindSubjectAction(): Action
|
|
{
|
|
$action = Action::make('bindSubject')
|
|
->label('Bind subject')
|
|
->icon('heroicon-o-link')
|
|
->visible(fn (Model $record): bool => (int) $record->getAttribute('candidate_count') > 0)
|
|
->form([
|
|
Select::make('candidate_key')
|
|
->label('Provider resource')
|
|
->required()
|
|
->native(false)
|
|
->options(fn (Model $record): array => collect($record->getAttribute('candidates') ?? [])
|
|
->mapWithKeys(fn (array $candidate): array => [
|
|
(string) $candidate['candidate_key'] => trim(implode(' - ', array_filter([
|
|
(string) ($candidate['display_label'] ?? 'Provider resource'),
|
|
(string) ($candidate['provider_label'] ?? ''),
|
|
(string) ($candidate['stable_identity_preview'] ?? ''),
|
|
]))),
|
|
])
|
|
->all()),
|
|
Textarea::make('operator_note')
|
|
->label('Operator note')
|
|
->required()
|
|
->minLength(8)
|
|
->rows(4)
|
|
->helperText('TenantPilot decision only. This does not mutate the provider tenant.'),
|
|
])
|
|
->requiresConfirmation()
|
|
->modalHeading('Bind subject')
|
|
->modalDescription('Record a TenantPilot-only subject binding. Future baseline compares can consume this active decision.')
|
|
->modalSubmitActionLabel('Bind subject')
|
|
->action(function (Model $record, array $data): void {
|
|
$tenant = $this->authorizedMutationEnvironment();
|
|
$actor = $this->actorOrAbort();
|
|
$row = $this->freshRowOrAbort($record);
|
|
$candidate = $this->candidateFromRow($row, (string) ($data['candidate_key'] ?? ''));
|
|
|
|
app(ProviderResourceBindingService::class)->createManualBinding(
|
|
actor: $actor,
|
|
environment: $tenant,
|
|
identity: ResourceIdentity::fromArray($candidate['identity']),
|
|
attributes: $this->bindingAttributes($row, $data, $candidate),
|
|
);
|
|
|
|
Notification::make()
|
|
->success()
|
|
->title('Subject binding recorded')
|
|
->body('Run baseline compare again to validate the decision against current evidence.')
|
|
->send();
|
|
|
|
$this->resetTable();
|
|
});
|
|
|
|
return UiEnforcement::forTableAction($action, fn (): ?ManagedEnvironment => $this->currentEnvironment())
|
|
->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE)
|
|
->preserveVisibility()
|
|
->apply();
|
|
}
|
|
|
|
private function recordDecisionAction(): Action
|
|
{
|
|
$action = Action::make('recordDecision')
|
|
->label('Record decision')
|
|
->icon('heroicon-o-check-circle')
|
|
->color('gray')
|
|
->visible(fn (Model $record): bool => is_array($record->getAttribute('decision_identity')) && $record->getAttribute('active_binding_id') === null)
|
|
->form([
|
|
Select::make('decision')
|
|
->label('Decision')
|
|
->required()
|
|
->native(false)
|
|
->options([
|
|
'excluded_non_governed' => 'Exclude subject',
|
|
'accepted_limitation' => 'Accept limitation',
|
|
'unsupported_coverage' => 'Mark unsupported',
|
|
'missing_expected' => 'Mark missing expected',
|
|
]),
|
|
Textarea::make('operator_note')
|
|
->label('Operator note')
|
|
->required()
|
|
->minLength(8)
|
|
->rows(4)
|
|
->helperText('TenantPilot decision only. This does not mutate the provider tenant.'),
|
|
])
|
|
->requiresConfirmation()
|
|
->modalHeading('Record subject decision')
|
|
->modalDescription('Record a TenantPilot-only decision for this baseline subject. Future baseline compares can consume the active decision.')
|
|
->modalSubmitActionLabel('Record decision')
|
|
->action(function (Model $record, array $data): void {
|
|
$tenant = $this->authorizedMutationEnvironment();
|
|
$actor = $this->actorOrAbort();
|
|
$row = $this->freshRowOrAbort($record);
|
|
$identityPayload = $row['decision_identity'] ?? null;
|
|
|
|
if (! is_array($identityPayload)) {
|
|
throw new InvalidArgumentException('A valid provider resource identity is required for this decision.');
|
|
}
|
|
|
|
$identity = ResourceIdentity::fromArray($identityPayload);
|
|
$attributes = $this->bindingAttributes($row, $data);
|
|
|
|
match ((string) ($data['decision'] ?? '')) {
|
|
'excluded_non_governed' => app(ProviderResourceBindingService::class)->createExclusion($actor, $tenant, $identity, $attributes),
|
|
'accepted_limitation' => app(ProviderResourceBindingService::class)->createAcceptedLimitation($actor, $tenant, $identity, $attributes),
|
|
'unsupported_coverage' => app(ProviderResourceBindingService::class)->markUnsupported($actor, $tenant, $identity, $attributes),
|
|
'missing_expected' => app(ProviderResourceBindingService::class)->markMissingExpected($actor, $tenant, $identity, $attributes),
|
|
default => throw new InvalidArgumentException('Unsupported baseline subject decision.'),
|
|
};
|
|
|
|
Notification::make()
|
|
->success()
|
|
->title('Subject decision recorded')
|
|
->body('Run baseline compare again to validate the decision against current evidence.')
|
|
->send();
|
|
|
|
$this->resetTable();
|
|
});
|
|
|
|
return UiEnforcement::forTableAction($action, fn (): ?ManagedEnvironment => $this->currentEnvironment())
|
|
->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE)
|
|
->preserveVisibility()
|
|
->apply();
|
|
}
|
|
|
|
private function revokeDecisionAction(): Action
|
|
{
|
|
$action = Action::make('revokeDecision')
|
|
->label('Revoke decision')
|
|
->icon('heroicon-o-no-symbol')
|
|
->color('danger')
|
|
->visible(fn (Model $record): bool => is_numeric($record->getAttribute('active_binding_id')))
|
|
->form([
|
|
Textarea::make('operator_note')
|
|
->label('Operator note')
|
|
->required()
|
|
->minLength(8)
|
|
->rows(4)
|
|
->helperText('TenantPilot decision only. This does not mutate the provider tenant.'),
|
|
])
|
|
->requiresConfirmation()
|
|
->modalHeading('Revoke subject decision')
|
|
->modalDescription('Revoke the active TenantPilot subject decision. Future baseline compares will no longer consume this decision.')
|
|
->modalSubmitActionLabel('Revoke decision')
|
|
->action(function (Model $record, array $data): void {
|
|
$this->authorizedMutationEnvironment();
|
|
$actor = $this->actorOrAbort();
|
|
$row = $this->freshRowOrAbort($record);
|
|
$bindingId = $row['active_binding_id'] ?? null;
|
|
|
|
if (! is_numeric($bindingId)) {
|
|
throw new NotFoundHttpException;
|
|
}
|
|
|
|
$binding = ProviderResourceBinding::query()
|
|
->whereKey((int) $bindingId)
|
|
->firstOrFail();
|
|
|
|
Gate::forUser($actor)->authorize('revoke', $binding);
|
|
|
|
app(ProviderResourceBindingService::class)->revoke(
|
|
actor: $actor,
|
|
binding: $binding,
|
|
operatorNote: (string) ($data['operator_note'] ?? ''),
|
|
resolutionReason: 'baseline_subject_resolution_revoked',
|
|
);
|
|
|
|
Notification::make()
|
|
->success()
|
|
->title('Subject decision revoked')
|
|
->body('Run baseline compare again to validate the updated decision state.')
|
|
->send();
|
|
|
|
$this->resetTable();
|
|
});
|
|
|
|
return UiEnforcement::forTableAction($action, fn (): ?ManagedEnvironment => $this->currentEnvironment())
|
|
->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE)
|
|
->destructive()
|
|
->preserveVisibility()
|
|
->apply();
|
|
}
|
|
|
|
private function runCompareAction(string $name): Action
|
|
{
|
|
$label = 'Run comparison again';
|
|
|
|
$action = Action::make($name)
|
|
->label($label)
|
|
->icon('heroicon-o-arrow-path')
|
|
->requiresConfirmation()
|
|
->modalHeading($label)
|
|
->modalDescription('This delegates to the existing baseline compare start flow and queues a compare operation for the current environment.')
|
|
->modalSubmitActionLabel($label)
|
|
->action(function (): void {
|
|
$this->startBaselineCompare();
|
|
});
|
|
|
|
return UiEnforcement::forAction($action, fn (): ?ManagedEnvironment => $this->currentEnvironment())
|
|
->requireCapability(Capabilities::TENANT_SYNC)
|
|
->apply();
|
|
}
|
|
|
|
private function runCompareEmptyStateAction(): Action
|
|
{
|
|
return $this->runCompareAction('runComparisonAgainFromEmptyState')
|
|
->visible(fn (): bool => ! $this->summary()['has_run']);
|
|
}
|
|
|
|
private function startBaselineCompare(): void
|
|
{
|
|
$tenant = $this->currentEnvironment();
|
|
$user = auth()->user();
|
|
|
|
if (! $tenant instanceof ManagedEnvironment) {
|
|
Notification::make()->title('Open an environment to compare baselines')->danger()->send();
|
|
|
|
return;
|
|
}
|
|
|
|
if (! $user instanceof User) {
|
|
Notification::make()->title('Not authenticated')->danger()->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$result = app(BaselineCompareService::class)->startCompare($tenant, $user);
|
|
|
|
if (! ($result['ok'] ?? false)) {
|
|
$reasonCode = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown';
|
|
$translation = is_array($result['reason_translation'] ?? null) ? $result['reason_translation'] : [];
|
|
$message = is_string($translation['short_explanation'] ?? null) && trim((string) $translation['short_explanation']) !== ''
|
|
? trim((string) $translation['short_explanation'])
|
|
: 'Reason: '.$reasonCode;
|
|
|
|
Notification::make()
|
|
->title('Cannot start comparison')
|
|
->body($message)
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$run = $result['run'] ?? null;
|
|
|
|
if ($run instanceof OperationRun) {
|
|
$this->focusedOperationRunId = (int) $run->getKey();
|
|
}
|
|
|
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
|
|
|
OperationUxPresenter::queuedToast($run instanceof OperationRun ? (string) $run->type : OperationRunType::BaselineCompare->value)
|
|
->actions($run instanceof OperationRun ? [
|
|
Action::make('view_run')
|
|
->label('Open operation')
|
|
->url(OperationRunLinks::view($run, $tenant)),
|
|
] : [])
|
|
->send();
|
|
|
|
$this->resetTable();
|
|
}
|
|
|
|
/**
|
|
* @return array<string, int|string|null>
|
|
*/
|
|
private function bindingAttributes(array $row, array $data, ?array $candidate = null): array
|
|
{
|
|
return [
|
|
'subject_domain' => (string) ($row['subject_domain'] ?? 'baseline'),
|
|
'subject_class' => (string) ($row['subject_class'] ?? 'policy_backed'),
|
|
'subject_type_key' => (string) ($row['subject_type_key'] ?? 'unknown'),
|
|
'canonical_subject_key' => is_string($row['canonical_subject_key'] ?? null) ? (string) $row['canonical_subject_key'] : null,
|
|
'display_label' => is_string($candidate['display_label'] ?? null)
|
|
? (string) $candidate['display_label']
|
|
: (string) ($row['subject_label'] ?? ''),
|
|
'resolution_reason' => (string) ($row['reason'] ?? 'baseline_subject_resolution'),
|
|
'operator_note' => (string) ($data['operator_note'] ?? ''),
|
|
'source_operation_run_id' => is_numeric($row['source_operation_run_id'] ?? null) ? (int) $row['source_operation_run_id'] : null,
|
|
'source_baseline_snapshot_id' => is_numeric($row['source_baseline_snapshot_id'] ?? null) ? (int) $row['source_baseline_snapshot_id'] : null,
|
|
'source_inventory_item_id' => is_numeric($candidate['source_inventory_item_id'] ?? $row['source_inventory_item_id'] ?? null)
|
|
? (int) ($candidate['source_inventory_item_id'] ?? $row['source_inventory_item_id'])
|
|
: null,
|
|
'source_policy_version_id' => is_numeric($candidate['source_policy_version_id'] ?? $row['source_policy_version_id'] ?? null)
|
|
? (int) ($candidate['source_policy_version_id'] ?? $row['source_policy_version_id'])
|
|
: null,
|
|
];
|
|
}
|
|
|
|
private function candidateFromRow(array $row, string $candidateKey): array
|
|
{
|
|
$candidate = collect($row['candidates'] ?? [])
|
|
->first(fn (array $candidate): bool => (string) ($candidate['candidate_key'] ?? '') === $candidateKey);
|
|
|
|
if (! is_array($candidate) || ! is_array($candidate['identity'] ?? null)) {
|
|
throw new InvalidArgumentException('Select a valid provider resource candidate.');
|
|
}
|
|
|
|
return $candidate;
|
|
}
|
|
|
|
private function freshRowOrAbort(Model $record): array
|
|
{
|
|
$tenant = $this->currentEnvironment();
|
|
|
|
if (! $tenant instanceof ManagedEnvironment) {
|
|
throw new NotFoundHttpException;
|
|
}
|
|
|
|
$row = $this->query()->row(
|
|
environment: $tenant,
|
|
rowId: (string) $record->getKey(),
|
|
filters: [
|
|
'operation_run_id' => $this->focusedOperationRunId,
|
|
'include_resolved' => true,
|
|
],
|
|
);
|
|
|
|
if (! is_array($row)) {
|
|
throw new NotFoundHttpException;
|
|
}
|
|
|
|
return $row;
|
|
}
|
|
|
|
private function authorizedMutationEnvironment(): ManagedEnvironment
|
|
{
|
|
$tenant = $this->currentEnvironment();
|
|
$this->authorizeEnvironmentOrAbort($tenant, Capabilities::WORKSPACE_BASELINES_MANAGE);
|
|
|
|
return $tenant;
|
|
}
|
|
|
|
private function actorOrAbort(): User
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
return $user;
|
|
}
|
|
|
|
private function authorizeEnvironmentOrAbort(?ManagedEnvironment $environment, string $capability): void
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (! $environment instanceof ManagedEnvironment || ! $user instanceof User) {
|
|
abort(404);
|
|
}
|
|
|
|
if (! static::routeWorkspaceMatchesEnvironment($environment)) {
|
|
abort(404);
|
|
}
|
|
|
|
$decision = app(ManagedEnvironmentAccessScopeResolver::class)
|
|
->decision($user, $environment, $capability);
|
|
|
|
if ($decision->shouldDenyAsNotFound()) {
|
|
abort(404);
|
|
}
|
|
|
|
if ($decision->shouldDenyAsForbidden()) {
|
|
abort(403);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $filters
|
|
* @return Collection<int, array<string, mixed>>
|
|
*/
|
|
private function filteredRows(array $filters, ?string $search): Collection
|
|
{
|
|
$tenant = $this->currentEnvironment();
|
|
|
|
if (! $tenant instanceof ManagedEnvironment) {
|
|
return collect();
|
|
}
|
|
|
|
$normalizedFilters = [
|
|
'operation_run_id' => $this->focusedOperationRunId,
|
|
'provider' => data_get($filters, 'provider.value'),
|
|
'subject_class' => data_get($filters, 'subject_class.value'),
|
|
'resource_type' => data_get($filters, 'resource_type.value'),
|
|
'actionability' => data_get($filters, 'actionability.value'),
|
|
'readiness_impact' => data_get($filters, 'readiness_impact.value'),
|
|
'reason' => data_get($filters, 'reason.value'),
|
|
'active_binding' => data_get($filters, 'active_binding.value'),
|
|
'candidates' => data_get($filters, 'candidates.value'),
|
|
];
|
|
|
|
$rows = collect($this->query()->rows($tenant, $normalizedFilters));
|
|
$normalizedSearch = Str::lower(trim((string) $search));
|
|
|
|
if ($normalizedSearch === '') {
|
|
return $rows;
|
|
}
|
|
|
|
return $rows
|
|
->filter(fn (array $row): bool => str_contains((string) ($row['search_text'] ?? ''), $normalizedSearch))
|
|
->values();
|
|
}
|
|
|
|
/**
|
|
* @param Collection<int, array<string, mixed>> $rows
|
|
* @return Collection<int, array<string, mixed>>
|
|
*/
|
|
private function sortRows(Collection $rows, ?string $sortColumn, ?string $sortDirection): Collection
|
|
{
|
|
$allowed = [
|
|
'subject_label',
|
|
'reason_label',
|
|
'readiness_label',
|
|
'readiness_impact',
|
|
'actionability_label',
|
|
'candidate_count',
|
|
'current_decision_label',
|
|
'source_operation_run_id',
|
|
'provider_label',
|
|
'subject_class_label',
|
|
];
|
|
$sortColumn = in_array($sortColumn, $allowed, true) ? $sortColumn : 'readiness_impact';
|
|
$descending = Str::lower((string) ($sortDirection ?? 'asc')) === 'desc';
|
|
|
|
return $rows->sortBy(
|
|
fn (array $row): string|int => $sortColumn === 'candidate_count' || $sortColumn === 'source_operation_run_id'
|
|
? (int) ($row[$sortColumn] ?? 0)
|
|
: (string) ($row[$sortColumn] ?? ''),
|
|
SORT_NATURAL | SORT_FLAG_CASE,
|
|
$descending,
|
|
)->values();
|
|
}
|
|
|
|
/**
|
|
* @param Collection<int, array<string, mixed>> $rows
|
|
*/
|
|
private function paginateRows(Collection $rows, int $page, int $recordsPerPage): LengthAwarePaginator
|
|
{
|
|
$perPage = max(1, $recordsPerPage);
|
|
$currentPage = max(1, $page);
|
|
$items = $rows->forPage($currentPage, $perPage)
|
|
->values()
|
|
->map(fn (array $row): Model => $this->toTableRecord($row));
|
|
|
|
return new LengthAwarePaginator(
|
|
$items,
|
|
$rows->count(),
|
|
$perPage,
|
|
$currentPage,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $row
|
|
*/
|
|
private function toTableRecord(array $row): Model
|
|
{
|
|
$record = new class extends Model
|
|
{
|
|
public $timestamps = false;
|
|
|
|
public $incrementing = false;
|
|
|
|
protected $keyType = 'string';
|
|
|
|
protected $guarded = [];
|
|
|
|
protected $table = 'baseline_subject_resolution_rows';
|
|
};
|
|
|
|
$record->forceFill($row);
|
|
$record->exists = true;
|
|
|
|
return $record;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
private function filterOptions(string $key): array
|
|
{
|
|
$tenant = $this->currentEnvironment();
|
|
|
|
if (! $tenant instanceof ManagedEnvironment) {
|
|
return [];
|
|
}
|
|
|
|
return $this->query()->filterOptions($tenant, $this->focusedOperationRunId, $key);
|
|
}
|
|
|
|
private function emptyStateHeading(): string
|
|
{
|
|
$summary = $this->summary();
|
|
|
|
if (! $summary['has_run']) {
|
|
return 'Run baseline compare first';
|
|
}
|
|
|
|
if ($summary['legacy_payload_only']) {
|
|
return 'Structured subject decisions unavailable';
|
|
}
|
|
|
|
return 'No baseline subject decisions required';
|
|
}
|
|
|
|
private function emptyStateDescription(): string
|
|
{
|
|
return match ($this->emptyStateHeading()) {
|
|
'Run baseline compare first' => 'No baseline compare run exists for this environment yet.',
|
|
'Structured subject decisions unavailable' => 'The latest compare run does not contain Spec 383 subject outcome semantics. Run baseline compare again to refresh the decision worklist.',
|
|
default => 'The selected compare context has no unresolved or decision-required baseline subjects.',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function summary(): array
|
|
{
|
|
$tenant = $this->currentEnvironment();
|
|
|
|
if (! $tenant instanceof ManagedEnvironment) {
|
|
return ['has_run' => false, 'legacy_payload_only' => false, 'actionable_count' => 0];
|
|
}
|
|
|
|
return $this->query()->summary($tenant, $this->focusedOperationRunId);
|
|
}
|
|
|
|
private function readinessColor(string $readinessImpact): string
|
|
{
|
|
return match ($readinessImpact) {
|
|
'customer_blocker',
|
|
'internal_blocker' => 'danger',
|
|
'customer_limitation',
|
|
'internal_limitation' => 'warning',
|
|
'no_impact' => 'success',
|
|
default => 'gray',
|
|
};
|
|
}
|
|
|
|
private function scopedOperationRunIdFromQuery(ManagedEnvironment $tenant): ?int
|
|
{
|
|
$operationRunId = request()->query('operation_run_id');
|
|
|
|
if (! is_numeric($operationRunId)) {
|
|
return null;
|
|
}
|
|
|
|
$run = $this->query()->resolveRun($tenant, (int) $operationRunId);
|
|
|
|
return $run instanceof OperationRun ? (int) $run->getKey() : null;
|
|
}
|
|
|
|
protected static function resolveRouteOwnedEnvironment(ManagedEnvironment|string|null $environment = null): ?ManagedEnvironment
|
|
{
|
|
if ($environment instanceof ManagedEnvironment) {
|
|
return $environment;
|
|
}
|
|
|
|
if (is_string($environment) && $environment !== '') {
|
|
return ManagedEnvironment::query()
|
|
->where('slug', $environment)
|
|
->first();
|
|
}
|
|
|
|
$routeEnvironment = request()->route('environment') ?? request()->route('tenant');
|
|
|
|
if ($routeEnvironment instanceof ManagedEnvironment) {
|
|
return $routeEnvironment;
|
|
}
|
|
|
|
if (is_string($routeEnvironment) && $routeEnvironment !== '') {
|
|
return ManagedEnvironment::query()
|
|
->where('slug', $routeEnvironment)
|
|
->first();
|
|
}
|
|
|
|
$refererEnvironment = static::resolveRefererOwnedEnvironment();
|
|
|
|
if ($refererEnvironment instanceof ManagedEnvironment) {
|
|
return $refererEnvironment;
|
|
}
|
|
|
|
$filamentTenant = Filament::getTenant();
|
|
|
|
return $filamentTenant instanceof ManagedEnvironment ? $filamentTenant : null;
|
|
}
|
|
|
|
private static function resolveRefererOwnedEnvironment(): ?ManagedEnvironment
|
|
{
|
|
$referer = request()->headers->get('referer');
|
|
|
|
if (! is_string($referer) || $referer === '') {
|
|
return null;
|
|
}
|
|
|
|
$path = parse_url($referer, PHP_URL_PATH);
|
|
|
|
if (! is_string($path)) {
|
|
return null;
|
|
}
|
|
|
|
if (preg_match('#^/admin/workspaces/([^/]+)/environments/([^/]+)/baseline-subject-resolution$#', $path, $matches) !== 1) {
|
|
return null;
|
|
}
|
|
|
|
$workspaceRouteKey = rawurldecode($matches[1]);
|
|
$environmentRouteKey = rawurldecode($matches[2]);
|
|
|
|
$environment = ManagedEnvironment::query()
|
|
->where('slug', $environmentRouteKey)
|
|
->first();
|
|
|
|
if (! $environment instanceof ManagedEnvironment) {
|
|
return null;
|
|
}
|
|
|
|
$workspace = $environment->workspace instanceof Workspace
|
|
? $environment->workspace
|
|
: $environment->workspace()->first();
|
|
|
|
if (! $workspace instanceof Workspace) {
|
|
return null;
|
|
}
|
|
|
|
if ($workspaceRouteKey !== static::workspaceRouteKey($workspace) && $workspaceRouteKey !== (string) $workspace->getKey()) {
|
|
return null;
|
|
}
|
|
|
|
return $environment;
|
|
}
|
|
|
|
private static function routeWorkspaceMatchesEnvironment(ManagedEnvironment $environment): bool
|
|
{
|
|
$routeWorkspace = request()->route('workspace');
|
|
|
|
if ($routeWorkspace instanceof Workspace) {
|
|
return (int) $routeWorkspace->getKey() === (int) $environment->workspace_id;
|
|
}
|
|
|
|
if (! is_string($routeWorkspace) && ! is_int($routeWorkspace)) {
|
|
return true;
|
|
}
|
|
|
|
$routeWorkspace = trim((string) $routeWorkspace);
|
|
|
|
if ($routeWorkspace === '') {
|
|
return true;
|
|
}
|
|
|
|
$workspace = $environment->workspace instanceof Workspace
|
|
? $environment->workspace
|
|
: $environment->workspace()->first();
|
|
|
|
if (! $workspace instanceof Workspace) {
|
|
return false;
|
|
}
|
|
|
|
return $routeWorkspace === static::workspaceRouteKey($workspace)
|
|
|| $routeWorkspace === (string) $workspace->getKey();
|
|
}
|
|
|
|
/**
|
|
* @param array<mixed> $parameters
|
|
*/
|
|
protected static function resolveAdminUrlEnvironment(array $parameters, ?Model $tenant = null): ?ManagedEnvironment
|
|
{
|
|
if ($tenant instanceof ManagedEnvironment) {
|
|
return $tenant;
|
|
}
|
|
|
|
foreach (['environment', 'tenant', 'managed_environment_id', 'environment_id', 'tenant_id'] as $key) {
|
|
$value = $parameters[$key] ?? null;
|
|
|
|
if ($value instanceof ManagedEnvironment) {
|
|
return $value;
|
|
}
|
|
|
|
if (is_numeric($value)) {
|
|
return ManagedEnvironment::query()->whereKey((int) $value)->first();
|
|
}
|
|
|
|
if (is_string($value) && trim($value) !== '') {
|
|
return ManagedEnvironment::query()->where('slug', trim($value))->first();
|
|
}
|
|
}
|
|
|
|
return Filament::getTenant() instanceof ManagedEnvironment ? Filament::getTenant() : null;
|
|
}
|
|
|
|
/**
|
|
* @param array<mixed> $parameters
|
|
*/
|
|
protected static function resolveAdminUrlWorkspace(ManagedEnvironment $environment, array $parameters = []): Workspace|string|int|null
|
|
{
|
|
$workspace = $parameters['workspace'] ?? null;
|
|
|
|
if ($workspace instanceof Workspace || is_string($workspace) || is_int($workspace)) {
|
|
return $workspace;
|
|
}
|
|
|
|
return Workspace::query()->whereKey((int) $environment->workspace_id)->first();
|
|
}
|
|
|
|
protected static function workspaceRouteKey(Workspace $workspace): string
|
|
{
|
|
$slug = $workspace->getAttribute('slug');
|
|
|
|
return is_string($slug) && $slug !== ''
|
|
? $slug
|
|
: (string) $workspace->getKey();
|
|
}
|
|
|
|
/**
|
|
* @param array<mixed> $parameters
|
|
* @return array<mixed>
|
|
*/
|
|
protected static function withoutLegacyScopeQuery(array $parameters): array
|
|
{
|
|
foreach (self::LEGACY_SCOPE_QUERY_KEYS as $key) {
|
|
unset($parameters[$key]);
|
|
}
|
|
|
|
return $parameters;
|
|
}
|
|
|
|
private function query(): BaselineSubjectResolutionQuery
|
|
{
|
|
return app(BaselineSubjectResolutionQuery::class);
|
|
}
|
|
}
|