385 lines
14 KiB
PHP
385 lines
14 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Filament\Resources\BaselineProfileResource\Pages;
|
|
|
|
use App\Filament\Resources\BaselineProfileResource;
|
|
use App\Models\BaselineProfile;
|
|
use App\Models\BaselineTenantAssignment;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Models\Workspace;
|
|
use App\Services\Auth\CapabilityResolver;
|
|
use App\Services\Baselines\BaselineCaptureService;
|
|
use App\Services\Baselines\BaselineCompareService;
|
|
use App\Support\Auth\Capabilities;
|
|
use App\Support\Baselines\BaselineCaptureMode;
|
|
use App\Support\Baselines\BaselineReasonCodes;
|
|
use App\Support\OperationRunLinks;
|
|
use App\Support\OpsUx\OperationUxPresenter;
|
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
|
use App\Support\Rbac\WorkspaceUiEnforcement;
|
|
use App\Support\Workspaces\WorkspaceContext;
|
|
use Filament\Actions\Action;
|
|
use Filament\Actions\EditAction;
|
|
use Filament\Forms\Components\Select;
|
|
use Filament\Notifications\Notification;
|
|
use Filament\Resources\Pages\ViewRecord;
|
|
|
|
class ViewBaselineProfile extends ViewRecord
|
|
{
|
|
protected static string $resource = BaselineProfileResource::class;
|
|
|
|
protected function getHeaderActions(): array
|
|
{
|
|
return [
|
|
$this->captureAction(),
|
|
$this->compareNowAction(),
|
|
EditAction::make()
|
|
->visible(fn (): bool => $this->hasManageCapability()),
|
|
];
|
|
}
|
|
|
|
private function captureAction(): Action
|
|
{
|
|
/** @var BaselineProfile $profile */
|
|
$profile = $this->getRecord();
|
|
|
|
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
|
|
? $profile->capture_mode
|
|
: BaselineCaptureMode::Opportunistic;
|
|
|
|
$label = $captureMode === BaselineCaptureMode::FullContent
|
|
? 'Capture baseline (full content)'
|
|
: 'Capture baseline';
|
|
|
|
$modalDescription = $captureMode === BaselineCaptureMode::FullContent
|
|
? 'Select the source tenant. This will capture content evidence on demand (redacted) and may take longer depending on scope.'
|
|
: 'Select the source tenant whose current inventory will be captured as the baseline snapshot.';
|
|
|
|
$action = Action::make('capture')
|
|
->label($label)
|
|
->icon('heroicon-o-camera')
|
|
->color('primary')
|
|
->requiresConfirmation()
|
|
->modalHeading($label)
|
|
->modalDescription($modalDescription)
|
|
->form([
|
|
Select::make('source_tenant_id')
|
|
->label('Source Tenant')
|
|
->options(fn (): array => $this->getWorkspaceTenantOptions())
|
|
->required()
|
|
->searchable(),
|
|
])
|
|
->action(function (array $data): void {
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
/** @var BaselineProfile $profile */
|
|
$profile = $this->getRecord();
|
|
$sourceTenant = Tenant::query()->find((int) $data['source_tenant_id']);
|
|
|
|
if (! $sourceTenant instanceof Tenant) {
|
|
Notification::make()
|
|
->title('Source tenant not found')
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$service = app(BaselineCaptureService::class);
|
|
$result = $service->startCapture($profile, $sourceTenant, $user);
|
|
|
|
if (! $result['ok']) {
|
|
$reasonCode = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown';
|
|
|
|
$message = match ($reasonCode) {
|
|
BaselineReasonCodes::CAPTURE_ROLLOUT_DISABLED => 'Full-content baseline capture is currently disabled for controlled rollout.',
|
|
BaselineReasonCodes::CAPTURE_PROFILE_NOT_ACTIVE => 'This baseline profile is not active.',
|
|
BaselineReasonCodes::CAPTURE_MISSING_SOURCE_TENANT => 'The selected tenant is not available for this baseline profile.',
|
|
default => 'Reason: '.str_replace('.', ' ', $reasonCode),
|
|
};
|
|
|
|
Notification::make()
|
|
->title('Cannot start capture')
|
|
->body($message)
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$run = $result['run'] ?? null;
|
|
|
|
if (! $run instanceof \App\Models\OperationRun) {
|
|
Notification::make()
|
|
->title('Cannot start capture')
|
|
->body('Reason: missing operation run')
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$viewAction = Action::make('view_run')
|
|
->label('View run')
|
|
->url(OperationRunLinks::view($run, $sourceTenant));
|
|
|
|
if (! $run->wasRecentlyCreated && in_array((string) $run->status, ['queued', 'running'], true)) {
|
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
|
|
|
OperationUxPresenter::alreadyQueuedToast((string) $run->type)
|
|
->actions([$viewAction])
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
|
|
|
OperationUxPresenter::queuedToast((string) $run->type)
|
|
->actions([$viewAction])
|
|
->send();
|
|
});
|
|
|
|
return WorkspaceUiEnforcement::forTableAction($action, fn (): ?Workspace => Workspace::query()
|
|
->whereKey((int) $this->getRecord()->workspace_id)
|
|
->first())
|
|
->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE)
|
|
->apply();
|
|
}
|
|
|
|
private function compareNowAction(): Action
|
|
{
|
|
/** @var BaselineProfile $profile */
|
|
$profile = $this->getRecord();
|
|
|
|
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
|
|
? $profile->capture_mode
|
|
: BaselineCaptureMode::Opportunistic;
|
|
|
|
$label = $captureMode === BaselineCaptureMode::FullContent
|
|
? 'Compare now (full content)'
|
|
: 'Compare now';
|
|
|
|
$modalDescription = $captureMode === BaselineCaptureMode::FullContent
|
|
? 'Select the target tenant. This will refresh content evidence on demand (redacted) before comparing.'
|
|
: 'Select the target tenant to compare its current inventory against the active baseline snapshot.';
|
|
|
|
return Action::make('compareNow')
|
|
->label($label)
|
|
->icon('heroicon-o-play')
|
|
->requiresConfirmation()
|
|
->modalHeading($label)
|
|
->modalDescription($modalDescription)
|
|
->form([
|
|
Select::make('target_tenant_id')
|
|
->label('Target Tenant')
|
|
->options(fn (): array => $this->getEligibleCompareTenantOptions())
|
|
->required()
|
|
->searchable(),
|
|
])
|
|
->disabled(fn (): bool => $this->getEligibleCompareTenantOptions() === [])
|
|
->action(function (array $data): void {
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
/** @var BaselineProfile $profile */
|
|
$profile = $this->getRecord();
|
|
|
|
$targetTenant = Tenant::query()->find((int) $data['target_tenant_id']);
|
|
|
|
if (! $targetTenant instanceof Tenant || (int) $targetTenant->workspace_id !== (int) $profile->workspace_id) {
|
|
Notification::make()
|
|
->title('Target tenant not found')
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$assignment = BaselineTenantAssignment::query()
|
|
->where('workspace_id', (int) $profile->workspace_id)
|
|
->where('tenant_id', (int) $targetTenant->getKey())
|
|
->where('baseline_profile_id', (int) $profile->getKey())
|
|
->first();
|
|
|
|
if (! $assignment instanceof BaselineTenantAssignment) {
|
|
Notification::make()
|
|
->title('Tenant not assigned')
|
|
->body('This tenant is not assigned to this baseline profile.')
|
|
->warning()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$resolver = app(CapabilityResolver::class);
|
|
|
|
if (! $resolver->can($user, $targetTenant, Capabilities::TENANT_SYNC)) {
|
|
Notification::make()
|
|
->title('Permission denied')
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$service = app(BaselineCompareService::class);
|
|
$result = $service->startCompare($targetTenant, $user);
|
|
|
|
if (! ($result['ok'] ?? false)) {
|
|
$reasonCode = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown';
|
|
|
|
$message = match ($reasonCode) {
|
|
BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => 'Full-content baseline compare is currently disabled for controlled rollout.',
|
|
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'This baseline profile is not active.',
|
|
BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT => 'This baseline profile has no active snapshot.',
|
|
default => 'Reason: '.str_replace('.', ' ', $reasonCode),
|
|
};
|
|
|
|
Notification::make()
|
|
->title('Cannot start comparison')
|
|
->body($message)
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$run = $result['run'] ?? null;
|
|
|
|
if (! $run instanceof \App\Models\OperationRun) {
|
|
Notification::make()
|
|
->title('Cannot start comparison')
|
|
->body('Reason: missing operation run')
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$viewAction = Action::make('view_run')
|
|
->label('View run')
|
|
->url(OperationRunLinks::view($run, $targetTenant));
|
|
|
|
if (! $run->wasRecentlyCreated && in_array((string) $run->status, ['queued', 'running'], true)) {
|
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
|
|
|
OperationUxPresenter::alreadyQueuedToast((string) $run->type)
|
|
->actions([$viewAction])
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
|
|
|
OperationUxPresenter::queuedToast((string) $run->type)
|
|
->actions([$viewAction])
|
|
->send();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
private function getWorkspaceTenantOptions(): array
|
|
{
|
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
|
|
|
if ($workspaceId === null) {
|
|
return [];
|
|
}
|
|
|
|
return Tenant::query()
|
|
->where('workspace_id', $workspaceId)
|
|
->orderBy('name')
|
|
->pluck('name', 'id')
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
private function getEligibleCompareTenantOptions(): array
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
return [];
|
|
}
|
|
|
|
/** @var BaselineProfile $profile */
|
|
$profile = $this->getRecord();
|
|
|
|
$tenantIds = BaselineTenantAssignment::query()
|
|
->where('workspace_id', (int) $profile->workspace_id)
|
|
->where('baseline_profile_id', (int) $profile->getKey())
|
|
->pluck('tenant_id')
|
|
->all();
|
|
|
|
if ($tenantIds === []) {
|
|
return [];
|
|
}
|
|
|
|
$resolver = app(CapabilityResolver::class);
|
|
|
|
$options = [];
|
|
|
|
$tenants = Tenant::query()
|
|
->where('workspace_id', (int) $profile->workspace_id)
|
|
->whereIn('id', $tenantIds)
|
|
->orderBy('name')
|
|
->get(['id', 'name']);
|
|
|
|
foreach ($tenants as $tenant) {
|
|
if (! $tenant instanceof Tenant) {
|
|
continue;
|
|
}
|
|
|
|
if (! $resolver->can($user, $tenant, Capabilities::TENANT_SYNC)) {
|
|
continue;
|
|
}
|
|
|
|
$options[(int) $tenant->getKey()] = (string) $tenant->name;
|
|
}
|
|
|
|
return $options;
|
|
}
|
|
|
|
private function hasManageCapability(): bool
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
return false;
|
|
}
|
|
|
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
|
|
|
if ($workspaceId === null) {
|
|
return false;
|
|
}
|
|
|
|
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
|
|
|
if (! $workspace instanceof Workspace) {
|
|
return false;
|
|
}
|
|
|
|
$resolver = app(\App\Services\Auth\WorkspaceCapabilityResolver::class);
|
|
|
|
return $resolver->isMember($user, $workspace)
|
|
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
|
|
}
|
|
}
|