Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m12s
Added BaselineSubjectResolution page and supporting logic to visualize missing identities, ambiguous matches, and skipped coverages per Spec 384.
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);
|
|
}
|
|
}
|