Implements Spec 110 Ops‑UX Enforcement and applies the repo‑wide “enterprise” standard for operation start + dedup surfaces. Key points - Start surfaces: only ephemeral queued toast (no DB notifications for started/queued/running). - Dedup paths: canonical “already queued” toast. - Progress refresh: dispatch run-enqueued browser event so the global widget updates immediately. - Completion: exactly-once terminal DB notification on completion (per Ops‑UX contract). Tests & formatting - Full suite: 1738 passed, 8 skipped (8477 assertions). - Pint: `vendor/bin/sail bin pint --dirty --format agent` (pass). Notable change - Removed legacy `RunStatusChangedNotification` (replaced by the terminal-only completion notification policy). Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #134
305 lines
9.9 KiB
PHP
305 lines
9.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Filament\Pages;
|
|
|
|
use App\Filament\Resources\FindingResource;
|
|
use App\Models\BaselineTenantAssignment;
|
|
use App\Models\Finding;
|
|
use App\Models\OperationRun;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Services\Auth\CapabilityResolver;
|
|
use App\Services\Baselines\BaselineCompareService;
|
|
use App\Support\Auth\Capabilities;
|
|
use App\Support\OperationRunLinks;
|
|
use App\Support\OpsUx\OperationUxPresenter;
|
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
|
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\Notifications\Notification;
|
|
use Filament\Pages\Page;
|
|
use UnitEnum;
|
|
|
|
class BaselineCompareLanding extends Page
|
|
{
|
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-scale';
|
|
|
|
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
|
|
|
protected static ?string $navigationLabel = 'Baseline Compare';
|
|
|
|
protected static ?int $navigationSort = 10;
|
|
|
|
protected static ?string $title = 'Baseline Compare';
|
|
|
|
protected string $view = 'filament.pages.baseline-compare-landing';
|
|
|
|
public ?string $state = null;
|
|
|
|
public ?string $message = null;
|
|
|
|
public ?string $profileName = null;
|
|
|
|
public ?int $profileId = null;
|
|
|
|
public ?int $snapshotId = null;
|
|
|
|
public ?int $operationRunId = null;
|
|
|
|
public ?int $findingsCount = null;
|
|
|
|
/** @var array<string, int>|null */
|
|
public ?array $severityCounts = null;
|
|
|
|
public ?string $lastComparedAt = null;
|
|
|
|
public static function canAccess(): bool
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
return false;
|
|
}
|
|
|
|
$tenant = Tenant::current();
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
return false;
|
|
}
|
|
|
|
$resolver = app(CapabilityResolver::class);
|
|
|
|
return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
|
|
}
|
|
|
|
public function mount(): void
|
|
{
|
|
$tenant = Tenant::current();
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
$this->state = 'no_tenant';
|
|
$this->message = 'No tenant selected.';
|
|
|
|
return;
|
|
}
|
|
|
|
$assignment = BaselineTenantAssignment::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->first();
|
|
|
|
if (! $assignment instanceof BaselineTenantAssignment) {
|
|
$this->state = 'no_assignment';
|
|
$this->message = 'This tenant has no baseline assignment. A workspace manager can assign a baseline profile to this tenant.';
|
|
|
|
return;
|
|
}
|
|
|
|
$profile = $assignment->baselineProfile;
|
|
|
|
if ($profile === null) {
|
|
$this->state = 'no_assignment';
|
|
$this->message = 'The assigned baseline profile no longer exists.';
|
|
|
|
return;
|
|
}
|
|
|
|
$this->profileName = (string) $profile->name;
|
|
$this->profileId = (int) $profile->getKey();
|
|
$this->snapshotId = $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null;
|
|
|
|
if ($this->snapshotId === null) {
|
|
$this->state = 'no_snapshot';
|
|
$this->message = 'The baseline profile has no active snapshot yet. A workspace manager needs to capture a snapshot first.';
|
|
|
|
return;
|
|
}
|
|
|
|
$latestRun = OperationRun::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->where('type', 'baseline_compare')
|
|
->latest('id')
|
|
->first();
|
|
|
|
if ($latestRun instanceof OperationRun && in_array($latestRun->status, ['queued', 'running'], true)) {
|
|
$this->state = 'comparing';
|
|
$this->operationRunId = (int) $latestRun->getKey();
|
|
$this->message = 'A baseline comparison is currently in progress.';
|
|
|
|
return;
|
|
}
|
|
|
|
if ($latestRun instanceof OperationRun && $latestRun->finished_at !== null) {
|
|
$this->lastComparedAt = $latestRun->finished_at->diffForHumans();
|
|
}
|
|
|
|
$scopeKey = 'baseline_profile:'.$profile->getKey();
|
|
|
|
$findingsQuery = Finding::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
|
->where('source', 'baseline.compare')
|
|
->where('scope_key', $scopeKey);
|
|
|
|
$totalFindings = (int) (clone $findingsQuery)->count();
|
|
|
|
if ($totalFindings > 0) {
|
|
$this->state = 'ready';
|
|
$this->findingsCount = $totalFindings;
|
|
$this->severityCounts = [
|
|
'high' => (int) (clone $findingsQuery)->where('severity', Finding::SEVERITY_HIGH)->count(),
|
|
'medium' => (int) (clone $findingsQuery)->where('severity', Finding::SEVERITY_MEDIUM)->count(),
|
|
'low' => (int) (clone $findingsQuery)->where('severity', Finding::SEVERITY_LOW)->count(),
|
|
];
|
|
|
|
if ($latestRun instanceof OperationRun) {
|
|
$this->operationRunId = (int) $latestRun->getKey();
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if ($latestRun instanceof OperationRun && $latestRun->status === 'completed' && $latestRun->outcome === 'succeeded') {
|
|
$this->state = 'ready';
|
|
$this->findingsCount = 0;
|
|
$this->operationRunId = (int) $latestRun->getKey();
|
|
$this->message = 'No drift findings for this baseline comparison. The tenant matches the baseline.';
|
|
|
|
return;
|
|
}
|
|
|
|
$this->state = 'idle';
|
|
$this->message = 'Baseline profile is assigned and has a snapshot. Run "Compare Now" to check for drift.';
|
|
}
|
|
|
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
{
|
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Compare Now (confirmation modal, capability-gated).')
|
|
->exempt(ActionSurfaceSlot::InspectAffordance, 'This is a tenant-scoped landing page, not a record inspect surface.')
|
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'This page does not render table rows with secondary actions.')
|
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'This page has no bulk actions.')
|
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Page renders explicit empty states for missing tenant, missing assignment, and missing snapshot, with guidance messaging.')
|
|
->exempt(ActionSurfaceSlot::DetailHeader, 'This page does not have a record detail header; it uses a page header action instead.');
|
|
}
|
|
|
|
/**
|
|
* @return array<Action>
|
|
*/
|
|
protected function getHeaderActions(): array
|
|
{
|
|
return [
|
|
$this->compareNowAction(),
|
|
];
|
|
}
|
|
|
|
private function compareNowAction(): Action
|
|
{
|
|
return Action::make('compareNow')
|
|
->label('Compare Now')
|
|
->icon('heroicon-o-play')
|
|
->requiresConfirmation()
|
|
->modalHeading('Start baseline comparison')
|
|
->modalDescription('This will compare the current tenant inventory against the assigned baseline snapshot and generate drift findings.')
|
|
->visible(fn (): bool => $this->canCompare())
|
|
->disabled(fn (): bool => ! in_array($this->state, ['idle', 'ready'], true))
|
|
->action(function (): void {
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
Notification::make()->title('Not authenticated')->danger()->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$tenant = Tenant::current();
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
Notification::make()->title('No tenant context')->danger()->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$service = app(BaselineCompareService::class);
|
|
$result = $service->startCompare($tenant, $user);
|
|
|
|
if (! ($result['ok'] ?? false)) {
|
|
Notification::make()
|
|
->title('Cannot start comparison')
|
|
->body('Reason: '.($result['reason_code'] ?? 'unknown'))
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$run = $result['run'] ?? null;
|
|
|
|
if ($run instanceof OperationRun) {
|
|
$this->operationRunId = (int) $run->getKey();
|
|
}
|
|
|
|
$this->state = 'comparing';
|
|
|
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
|
|
|
OperationUxPresenter::queuedToast($run instanceof OperationRun ? (string) $run->type : 'baseline_compare')
|
|
->actions($run instanceof OperationRun ? [
|
|
Action::make('view_run')
|
|
->label('View run')
|
|
->url(OperationRunLinks::view($run, $tenant)),
|
|
] : [])
|
|
->send();
|
|
});
|
|
}
|
|
|
|
private function canCompare(): bool
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
return false;
|
|
}
|
|
|
|
$tenant = Tenant::current();
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
return false;
|
|
}
|
|
|
|
$resolver = app(CapabilityResolver::class);
|
|
|
|
return $resolver->can($user, $tenant, Capabilities::TENANT_SYNC);
|
|
}
|
|
|
|
public function getFindingsUrl(): ?string
|
|
{
|
|
$tenant = Tenant::current();
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
return null;
|
|
}
|
|
|
|
return FindingResource::getUrl('index', tenant: $tenant);
|
|
}
|
|
|
|
public function getRunUrl(): ?string
|
|
{
|
|
if ($this->operationRunId === null) {
|
|
return null;
|
|
}
|
|
|
|
$tenant = Tenant::current();
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
return null;
|
|
}
|
|
|
|
return OperationRunLinks::view($this->operationRunId, $tenant);
|
|
}
|
|
}
|