feat: add cross-resource navigation cohesion #160
3
.github/agents/copilot-instructions.md
vendored
3
.github/agents/copilot-instructions.md
vendored
@ -59,6 +59,7 @@ ## Active Technologies
|
||||
- PostgreSQL via Laravel Sail (128-rbac-baseline-compare)
|
||||
- PostgreSQL via Laravel Sail plus session-backed workspace and tenant contex (129-workspace-admin-home)
|
||||
- PostgreSQL via Laravel Sail using existing `baseline_snapshots`, `baseline_snapshot_items`, and JSONB presentation source fields (130-structured-snapshot-rendering)
|
||||
- PostgreSQL via Laravel Sail, plus existing session-backed workspace and tenant contex (131-cross-resource-navigation)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -78,8 +79,8 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 131-cross-resource-navigation: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4
|
||||
- 130-structured-snapshot-rendering: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4
|
||||
- 129-workspace-admin-home: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4
|
||||
- 128-rbac-baseline-compare: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
use App\Filament\Widgets\Operations\OperationsKpiHeader;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
@ -31,6 +32,11 @@ class Operations extends Page implements HasForms, HasTable
|
||||
|
||||
public string $activeTab = 'all';
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>|null
|
||||
*/
|
||||
public ?array $navigationContextPayload = null;
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-queue-list';
|
||||
@ -44,6 +50,7 @@ class Operations extends Page implements HasForms, HasTable
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null;
|
||||
$this->mountInteractsWithTable();
|
||||
}
|
||||
|
||||
@ -60,6 +67,7 @@ protected function getHeaderWidgets(): array
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
$operateHubShell = app(OperateHubShell::class);
|
||||
$navigationContext = $this->navigationContext();
|
||||
|
||||
$actions = [
|
||||
Action::make('operate_hub_scope_operations')
|
||||
@ -70,13 +78,21 @@ protected function getHeaderActions(): array
|
||||
|
||||
$activeTenant = $operateHubShell->activeEntitledTenant(request());
|
||||
|
||||
if ($activeTenant instanceof Tenant) {
|
||||
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
|
||||
$actions[] = Action::make('operate_hub_back_to_origin_operations')
|
||||
->label($navigationContext->backLinkLabel)
|
||||
->icon('heroicon-o-arrow-left')
|
||||
->color('gray')
|
||||
->url($navigationContext->backLinkUrl);
|
||||
} elseif ($activeTenant instanceof Tenant) {
|
||||
$actions[] = Action::make('operate_hub_back_to_tenant_operations')
|
||||
->label('Back to '.$activeTenant->name)
|
||||
->icon('heroicon-o-arrow-left')
|
||||
->color('gray')
|
||||
->url(\App\Filament\Pages\TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant));
|
||||
}
|
||||
|
||||
if ($activeTenant instanceof Tenant) {
|
||||
$actions[] = Action::make('operate_hub_show_all_tenants')
|
||||
->label('Show all tenants')
|
||||
->color('gray')
|
||||
@ -94,6 +110,17 @@ protected function getHeaderActions(): array
|
||||
return $actions;
|
||||
}
|
||||
|
||||
private function navigationContext(): ?CanonicalNavigationContext
|
||||
{
|
||||
if (! is_array($this->navigationContextPayload)) {
|
||||
return CanonicalNavigationContext::fromRequest(request());
|
||||
}
|
||||
|
||||
$request = request()->duplicate(query: ['nav' => $this->navigationContextPayload]);
|
||||
|
||||
return CanonicalNavigationContext::fromRequest($request);
|
||||
}
|
||||
|
||||
public function updatedActiveTab(): void
|
||||
{
|
||||
$this->resetPage();
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Services\Baselines\BaselineEvidenceCaptureResumeService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
@ -40,6 +41,11 @@ class TenantlessOperationRunViewer extends Page
|
||||
|
||||
public OperationRun $run;
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>|null
|
||||
*/
|
||||
public ?array $navigationContextPayload = null;
|
||||
|
||||
public bool $opsUxIsTabHidden = false;
|
||||
|
||||
/**
|
||||
@ -48,6 +54,7 @@ class TenantlessOperationRunViewer extends Page
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
$operateHubShell = app(OperateHubShell::class);
|
||||
$navigationContext = $this->navigationContext();
|
||||
|
||||
$actions = [
|
||||
Action::make('operate_hub_scope_run_detail')
|
||||
@ -58,16 +65,16 @@ protected function getHeaderActions(): array
|
||||
|
||||
$activeTenant = $operateHubShell->activeEntitledTenant(request());
|
||||
|
||||
if ($activeTenant instanceof Tenant) {
|
||||
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
|
||||
$actions[] = Action::make('operate_hub_back_to_origin_run_detail')
|
||||
->label($navigationContext->backLinkLabel)
|
||||
->color('gray')
|
||||
->url($navigationContext->backLinkUrl);
|
||||
} elseif ($activeTenant instanceof Tenant) {
|
||||
$actions[] = Action::make('operate_hub_back_to_tenant_run_detail')
|
||||
->label('← Back to '.$activeTenant->name)
|
||||
->color('gray')
|
||||
->url(\App\Filament\Pages\TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant));
|
||||
|
||||
$actions[] = Action::make('operate_hub_show_all_operations')
|
||||
->label('Show all operations')
|
||||
->color('gray')
|
||||
->url(fn (): string => route('admin.operations.index'));
|
||||
} else {
|
||||
$actions[] = Action::make('operate_hub_back_to_operations')
|
||||
->label('Back to Operations')
|
||||
@ -75,12 +82,19 @@ protected function getHeaderActions(): array
|
||||
->url(fn (): string => route('admin.operations.index'));
|
||||
}
|
||||
|
||||
if ($activeTenant instanceof Tenant) {
|
||||
$actions[] = Action::make('operate_hub_show_all_operations')
|
||||
->label('Show all operations')
|
||||
->color('gray')
|
||||
->url(fn (): string => route('admin.operations.index'));
|
||||
}
|
||||
|
||||
$actions[] = Action::make('refresh')
|
||||
->label('Refresh')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('gray')
|
||||
->url(fn (): string => isset($this->run)
|
||||
? route('admin.operations.view', ['run' => (int) $this->run->getKey()])
|
||||
? OperationRunLinks::tenantlessView($this->run, $navigationContext)
|
||||
: route('admin.operations.index'));
|
||||
|
||||
if (! isset($this->run)) {
|
||||
@ -128,6 +142,7 @@ public function mount(OperationRun $run): void
|
||||
$this->authorize('view', $run);
|
||||
|
||||
$this->run = $run->loadMissing(['workspace', 'tenant', 'user']);
|
||||
$this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null;
|
||||
}
|
||||
|
||||
public function infolist(Schema $schema): Schema
|
||||
@ -228,6 +243,17 @@ private function resumeCaptureAction(): Action
|
||||
});
|
||||
}
|
||||
|
||||
private function navigationContext(): ?CanonicalNavigationContext
|
||||
{
|
||||
if (! is_array($this->navigationContextPayload)) {
|
||||
return CanonicalNavigationContext::fromRequest(request());
|
||||
}
|
||||
|
||||
$request = request()->duplicate(query: ['nav' => $this->navigationContextPayload]);
|
||||
|
||||
return CanonicalNavigationContext::fromRequest($request);
|
||||
}
|
||||
|
||||
private function canResumeCapture(): bool
|
||||
{
|
||||
if (! isset($this->run)) {
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Filament\FilterOptionCatalog;
|
||||
use App\Support\Filament\FilterPresets;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
@ -113,6 +114,7 @@ public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
$user = auth()->user();
|
||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->with(['tenant', 'rule', 'destination'])
|
||||
@ -136,8 +138,8 @@ public static function getEloquentQuery(): Builder
|
||||
}),
|
||||
)
|
||||
->when(
|
||||
Filament::getTenant() instanceof Tenant,
|
||||
fn (Builder $query): Builder => $query->where('tenant_id', (int) Filament::getTenant()->getKey()),
|
||||
$activeTenant instanceof Tenant,
|
||||
fn (Builder $query): Builder => $query->where('tenant_id', (int) $activeTenant->getKey()),
|
||||
)
|
||||
->latest('id');
|
||||
}
|
||||
@ -254,6 +256,45 @@ public static function table(Table $table): Table
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('tenant_id')
|
||||
->label('Tenant')
|
||||
->options(function (): array {
|
||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
|
||||
if ($activeTenant instanceof Tenant) {
|
||||
return [
|
||||
(string) $activeTenant->getKey() => $activeTenant->getFilamentName(),
|
||||
];
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return collect($user->getTenants(Filament::getCurrentOrDefaultPanel()))
|
||||
->mapWithKeys(static fn (Tenant $tenant): array => [
|
||||
(string) $tenant->getKey() => $tenant->getFilamentName(),
|
||||
])
|
||||
->all();
|
||||
})
|
||||
->default(function (): ?string {
|
||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
|
||||
if (! $activeTenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if ($workspaceId === null || (int) $activeTenant->workspace_id !== (int) $workspaceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (string) $activeTenant->getKey();
|
||||
})
|
||||
->searchable(),
|
||||
SelectFilter::make('status')
|
||||
->options(FilterOptionCatalog::alertDeliveryStatuses()),
|
||||
SelectFilter::make('event_type')
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
namespace App\Filament\Resources\AlertDeliveryResource\Pages;
|
||||
|
||||
use App\Filament\Resources\AlertDeliveryResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
@ -12,6 +13,27 @@ class ListAlertDeliveries extends ListRecords
|
||||
{
|
||||
protected static string $resource = AlertDeliveryResource::class;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
|
||||
if ($activeTenant instanceof Tenant) {
|
||||
$filtersSessionKey = $this->getTableFiltersSessionKey();
|
||||
$persistedFilters = session()->get($filtersSessionKey, []);
|
||||
|
||||
if (! is_array($persistedFilters)) {
|
||||
$persistedFilters = [];
|
||||
}
|
||||
|
||||
if (! is_string(data_get($persistedFilters, 'tenant_id.value'))) {
|
||||
data_set($persistedFilters, 'tenant_id.value', (string) $activeTenant->getKey());
|
||||
session()->put($filtersSessionKey, $persistedFilters);
|
||||
}
|
||||
}
|
||||
|
||||
parent::mount();
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return app(OperateHubShell::class)->headerActions(
|
||||
|
||||
@ -19,6 +19,9 @@
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||
use App\Support\Navigation\RelatedContextEntry;
|
||||
use App\Support\Navigation\RelatedNavigationResolver;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
@ -32,6 +35,7 @@
|
||||
use Filament\Infolists;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
@ -147,10 +151,9 @@ public static function table(Table $table): Table
|
||||
->trueLabel('All')
|
||||
->falseLabel('Archived'),
|
||||
])
|
||||
->recordUrl(fn (BackupSet $record): string => static::getUrl('view', ['record' => $record]))
|
||||
->actions([
|
||||
Actions\ViewAction::make()
|
||||
->url(fn (BackupSet $record) => static::getUrl('view', ['record' => $record]))
|
||||
->openUrlInNewTab(false),
|
||||
static::primaryRelatedAction(),
|
||||
ActionGroup::make([
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('restore')
|
||||
@ -531,6 +534,15 @@ public static function infolist(Schema $schema): Schema
|
||||
->formatStateUsing(fn ($state) => json_encode($state ?? [], JSON_PRETTY_PRINT))
|
||||
->copyable()
|
||||
->copyMessage('Metadata copied'),
|
||||
Section::make('Related context')
|
||||
->schema([
|
||||
Infolists\Components\ViewEntry::make('related_context')
|
||||
->label('')
|
||||
->view('filament.infolists.entries.related-context')
|
||||
->state(fn (BackupSet $record): array => static::relatedContextEntries($record))
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -568,6 +580,42 @@ private static function typeMeta(?string $type): array
|
||||
->firstWhere('type', $type) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{
|
||||
* key: string,
|
||||
* label: string,
|
||||
* value: string,
|
||||
* secondaryValue: ?string,
|
||||
* targetUrl: ?string,
|
||||
* targetKind: string,
|
||||
* availability: string,
|
||||
* unavailableReason: ?string,
|
||||
* contextBadge: ?string,
|
||||
* priority: int,
|
||||
* actionLabel: string
|
||||
* }>
|
||||
*/
|
||||
public static function relatedContextEntries(BackupSet $record): array
|
||||
{
|
||||
return app(RelatedNavigationResolver::class)
|
||||
->detailEntries(CrossResourceNavigationMatrix::SOURCE_BACKUP_SET, $record);
|
||||
}
|
||||
|
||||
private static function primaryRelatedAction(): Actions\Action
|
||||
{
|
||||
return Actions\Action::make('primary_drill_down')
|
||||
->label(fn (BackupSet $record): string => static::primaryRelatedEntry($record)?->actionLabel ?? 'Open related record')
|
||||
->url(fn (BackupSet $record): ?string => static::primaryRelatedEntry($record)?->targetUrl)
|
||||
->hidden(fn (BackupSet $record): bool => ! (static::primaryRelatedEntry($record)?->isAvailable() ?? false))
|
||||
->color('gray');
|
||||
}
|
||||
|
||||
private static function primaryRelatedEntry(BackupSet $record): ?RelatedContextEntry
|
||||
{
|
||||
return app(RelatedNavigationResolver::class)
|
||||
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_BACKUP_SET, $record);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a backup set via the domain service instead of direct model mass-assignment.
|
||||
*/
|
||||
|
||||
@ -3,9 +3,26 @@
|
||||
namespace App\Filament\Resources\BackupSetResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||
use App\Support\Navigation\RelatedNavigationResolver;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewBackupSet extends ViewRecord
|
||||
{
|
||||
protected static string $resource = BackupSetResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('primary_related')
|
||||
->label(fn (): string => app(RelatedNavigationResolver::class)
|
||||
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_BACKUP_SET, $this->getRecord())?->actionLabel ?? 'Open related record')
|
||||
->url(fn (): ?string => app(RelatedNavigationResolver::class)
|
||||
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_BACKUP_SET, $this->getRecord())?->targetUrl)
|
||||
->hidden(fn (): bool => ! (app(RelatedNavigationResolver::class)
|
||||
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_BACKUP_SET, $this->getRecord())?->isAvailable() ?? false))
|
||||
->color('gray'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,6 +16,9 @@
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||
use App\Support\Navigation\RelatedContextEntry;
|
||||
use App\Support\Navigation\RelatedNavigationResolver;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
@ -34,6 +37,11 @@ class ViewBaselineProfile extends ViewRecord
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('view_active_snapshot')
|
||||
->label(fn (): string => $this->activeSnapshotEntry()?->actionLabel ?? 'View snapshot')
|
||||
->url(fn (): ?string => $this->activeSnapshotEntry()?->targetUrl)
|
||||
->hidden(fn (): bool => ! ($this->activeSnapshotEntry()?->isAvailable() ?? false))
|
||||
->color('gray'),
|
||||
$this->captureAction(),
|
||||
$this->compareNowAction(),
|
||||
EditAction::make()
|
||||
@ -41,6 +49,12 @@ protected function getHeaderActions(): array
|
||||
];
|
||||
}
|
||||
|
||||
private function activeSnapshotEntry(): ?RelatedContextEntry
|
||||
{
|
||||
return app(RelatedNavigationResolver::class)
|
||||
->headerEntries(CrossResourceNavigationMatrix::SOURCE_BASELINE_PROFILE, $this->getRecord())[0] ?? null;
|
||||
}
|
||||
|
||||
private function captureAction(): Action
|
||||
{
|
||||
/** @var BaselineProfile $profile */
|
||||
|
||||
@ -11,12 +11,16 @@
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Filament\FilterPresets;
|
||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||
use App\Support\Navigation\RelatedContextEntry;
|
||||
use App\Support\Navigation\RelatedNavigationResolver;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
@ -185,7 +189,9 @@ public static function table(Table $table): Table
|
||||
->query(fn (Builder $query, array $data): Builder => static::applySnapshotStateFilter($query, $data['value'] ?? null)),
|
||||
FilterPresets::dateRange('captured_at', 'Captured', 'captured_at'),
|
||||
])
|
||||
->actions([])
|
||||
->actions([
|
||||
static::primaryRelatedAction(),
|
||||
])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading('No baseline snapshots')
|
||||
->emptyStateDescription('Capture a baseline snapshot to review evidence fidelity and compare tenants over time.')
|
||||
@ -197,6 +203,21 @@ public static function infolist(Schema $schema): Schema
|
||||
return $schema;
|
||||
}
|
||||
|
||||
private static function primaryRelatedAction(): Action
|
||||
{
|
||||
return Action::make('primary_drill_down')
|
||||
->label(fn (BaselineSnapshot $record): string => static::primaryRelatedEntry($record)?->actionLabel ?? 'Open related record')
|
||||
->url(fn (BaselineSnapshot $record): ?string => static::primaryRelatedEntry($record)?->targetUrl)
|
||||
->hidden(fn (BaselineSnapshot $record): bool => ! (static::primaryRelatedEntry($record)?->isAvailable() ?? false))
|
||||
->color('gray');
|
||||
}
|
||||
|
||||
private static function primaryRelatedEntry(BaselineSnapshot $record): ?RelatedContextEntry
|
||||
{
|
||||
return app(RelatedNavigationResolver::class)
|
||||
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_BASELINE_SNAPSHOT, $record);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
|
||||
@ -13,6 +13,10 @@
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||
use App\Support\Navigation\RelatedContextEntry;
|
||||
use App\Support\Navigation\RelatedNavigationResolver;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Infolists\Components\ViewEntry;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
@ -125,6 +129,16 @@ public function infolist(Schema $schema): Schema
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
Section::make('Related context')
|
||||
->schema([
|
||||
ViewEntry::make('related_context')
|
||||
->label('')
|
||||
->view('filament.infolists.entries.related-context')
|
||||
->state(fn (): array => app(RelatedNavigationResolver::class)
|
||||
->detailEntries(CrossResourceNavigationMatrix::SOURCE_BASELINE_SNAPSHOT, $this->getRecord()))
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
Section::make('Captured policy types')
|
||||
->schema([
|
||||
ViewEntry::make('groups')
|
||||
@ -150,6 +164,18 @@ public function infolist(Schema $schema): Schema
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
return [
|
||||
Action::make('primary_related')
|
||||
->label(fn (): string => $this->primaryRelatedEntry()?->actionLabel ?? 'Open related record')
|
||||
->url(fn (): ?string => $this->primaryRelatedEntry()?->targetUrl)
|
||||
->hidden(fn (): bool => ! ($this->primaryRelatedEntry()?->isAvailable() ?? false))
|
||||
->color('gray'),
|
||||
];
|
||||
}
|
||||
|
||||
private function primaryRelatedEntry(): ?RelatedContextEntry
|
||||
{
|
||||
return app(RelatedNavigationResolver::class)
|
||||
->headerEntries(CrossResourceNavigationMatrix::SOURCE_BASELINE_SNAPSHOT, $this->getRecord())[0] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,6 +17,11 @@
|
||||
use App\Support\Filament\FilterOptionCatalog;
|
||||
use App\Support\Filament\FilterPresets;
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||
use App\Support\Navigation\RelatedContextEntry;
|
||||
use App\Support\Navigation\RelatedNavigationResolver;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
use App\Support\RedactionIntegrity;
|
||||
@ -106,7 +111,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions support filtered findings operations (legacy acknowledge-all-matching remains until bulk workflow migration).')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".')
|
||||
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
|
||||
->exempt(ActionSurfaceSlot::ListEmptyState, 'Findings are generated by drift detection and intentionally have no create CTA.')
|
||||
@ -174,14 +179,16 @@ public static function infolist(Schema $schema): Schema
|
||||
TextEntry::make('subject_external_id')->label('External ID')->copyable(),
|
||||
TextEntry::make('baseline_operation_run_id')
|
||||
->label('Baseline run')
|
||||
->formatStateUsing(static fn (?int $state): string => $state ? '#'.$state : '—')
|
||||
->url(fn (Finding $record): ?string => $record->baseline_operation_run_id
|
||||
? route('admin.operations.view', ['run' => (int) $record->baseline_operation_run_id])
|
||||
? OperationRunLinks::tenantlessView((int) $record->baseline_operation_run_id, static::findingRunNavigationContext($record))
|
||||
: null)
|
||||
->openUrlInNewTab(),
|
||||
TextEntry::make('current_operation_run_id')
|
||||
->label('Current run')
|
||||
->formatStateUsing(static fn (?int $state): string => $state ? '#'.$state : '—')
|
||||
->url(fn (Finding $record): ?string => $record->current_operation_run_id
|
||||
? route('admin.operations.view', ['run' => (int) $record->current_operation_run_id])
|
||||
? OperationRunLinks::tenantlessView((int) $record->current_operation_run_id, static::findingRunNavigationContext($record))
|
||||
: null)
|
||||
->openUrlInNewTab(),
|
||||
TextEntry::make('acknowledged_at')->dateTime()->placeholder('—'),
|
||||
@ -267,6 +274,16 @@ public static function infolist(Schema $schema): Schema
|
||||
})
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Related context')
|
||||
->schema([
|
||||
ViewEntry::make('related_context')
|
||||
->label('')
|
||||
->view('filament.infolists.entries.related-context')
|
||||
->state(fn (Finding $record): array => static::relatedContextEntries($record))
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Diff')
|
||||
->visible(fn (Finding $record): bool => $record->finding_type === Finding::FINDING_TYPE_DRIFT)
|
||||
->schema([
|
||||
@ -687,8 +704,11 @@ public static function table(Table $table): Table
|
||||
}),
|
||||
FilterPresets::dateRange('created_at', 'Created', 'created_at'),
|
||||
])
|
||||
->recordUrl(static fn (Finding $record): ?string => static::canView($record)
|
||||
? static::getUrl('view', ['record' => $record])
|
||||
: null)
|
||||
->actions([
|
||||
Actions\ViewAction::make(),
|
||||
static::primaryRelatedAction(),
|
||||
Actions\ActionGroup::make([
|
||||
...static::workflowActions(),
|
||||
])
|
||||
@ -1091,6 +1111,60 @@ public static function getEloquentQuery(): Builder
|
||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{
|
||||
* key: string,
|
||||
* label: string,
|
||||
* value: string,
|
||||
* secondaryValue: ?string,
|
||||
* targetUrl: ?string,
|
||||
* targetKind: string,
|
||||
* availability: string,
|
||||
* unavailableReason: ?string,
|
||||
* contextBadge: ?string,
|
||||
* priority: int,
|
||||
* actionLabel: string
|
||||
* }>
|
||||
*/
|
||||
public static function relatedContextEntries(Finding $record): array
|
||||
{
|
||||
return app(RelatedNavigationResolver::class)
|
||||
->detailEntries(CrossResourceNavigationMatrix::SOURCE_FINDING, $record);
|
||||
}
|
||||
|
||||
private static function primaryRelatedAction(): Actions\Action
|
||||
{
|
||||
return Actions\Action::make('primary_drill_down')
|
||||
->label(fn (Finding $record): string => static::primaryRelatedEntry($record)?->actionLabel ?? 'Open related record')
|
||||
->url(fn (Finding $record): ?string => static::primaryRelatedEntry($record)?->targetUrl)
|
||||
->hidden(fn (Finding $record): bool => ! (static::primaryRelatedEntry($record)?->isAvailable() ?? false))
|
||||
->color('gray');
|
||||
}
|
||||
|
||||
private static function primaryRelatedEntry(Finding $record): ?RelatedContextEntry
|
||||
{
|
||||
return app(RelatedNavigationResolver::class)
|
||||
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $record);
|
||||
}
|
||||
|
||||
private static function findingRunNavigationContext(Finding $record): CanonicalNavigationContext
|
||||
{
|
||||
$tenant = $record->tenant;
|
||||
|
||||
return new CanonicalNavigationContext(
|
||||
sourceSurface: 'finding.detail_section',
|
||||
canonicalRouteName: 'admin.operations.view',
|
||||
tenantId: $tenant?->getKey(),
|
||||
backLinkLabel: 'Back to finding',
|
||||
backLinkUrl: static::getUrl('view', ['record' => $record], tenant: $tenant),
|
||||
filterPayload: $tenant instanceof Tenant ? [
|
||||
'tableFilters' => [
|
||||
'tenant_id' => ['value' => (string) $tenant->getKey()],
|
||||
],
|
||||
] : [],
|
||||
);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
namespace App\Filament\Resources\FindingResource\Pages;
|
||||
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||
use App\Support\Navigation\RelatedNavigationResolver;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Illuminate\Contracts\Support\Htmlable;
|
||||
@ -14,6 +16,14 @@ class ViewFinding extends ViewRecord
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\Action::make('primary_related')
|
||||
->label(fn (): string => app(RelatedNavigationResolver::class)
|
||||
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $this->getRecord())?->actionLabel ?? 'Open related record')
|
||||
->url(fn (): ?string => app(RelatedNavigationResolver::class)
|
||||
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $this->getRecord())?->targetUrl)
|
||||
->hidden(fn (): bool => ! (app(RelatedNavigationResolver::class)
|
||||
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $this->getRecord())?->isAvailable() ?? false))
|
||||
->color('gray'),
|
||||
Actions\ActionGroup::make(FindingResource::workflowActions())
|
||||
->label('Actions')
|
||||
->icon('heroicon-o-ellipsis-vertical')
|
||||
|
||||
@ -14,6 +14,8 @@
|
||||
use App\Support\Filament\FilterOptionCatalog;
|
||||
use App\Support\Filament\FilterPresets;
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||
use App\Support\Navigation\RelatedNavigationResolver;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunLinks;
|
||||
@ -177,6 +179,17 @@ public static function infolist(Schema $schema): Schema
|
||||
->visible(fn (OperationRun $record): bool => ! empty($record->summary_counts))
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Related context')
|
||||
->schema([
|
||||
ViewEntry::make('related_context')
|
||||
->label('')
|
||||
->view('filament.infolists.entries.related-context')
|
||||
->state(fn (OperationRun $record): array => app(RelatedNavigationResolver::class)
|
||||
->detailEntries(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, $record))
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Failures')
|
||||
->schema([
|
||||
ViewEntry::make('failure_summary')
|
||||
@ -655,7 +668,7 @@ public static function table(Table $table): Table
|
||||
->actions([
|
||||
Actions\ViewAction::make()
|
||||
->label('View run')
|
||||
->url(fn (OperationRun $record): string => route('admin.operations.view', ['run' => (int) $record->getKey()])),
|
||||
->url(fn (OperationRun $record): string => OperationRunLinks::tenantlessView($record)),
|
||||
])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading('No operation runs found')
|
||||
|
||||
@ -23,6 +23,9 @@
|
||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||
use App\Support\Filament\FilterOptionCatalog;
|
||||
use App\Support\Filament\FilterPresets;
|
||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||
use App\Support\Navigation\RelatedContextEntry;
|
||||
use App\Support\Navigation\RelatedNavigationResolver;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
@ -40,6 +43,7 @@
|
||||
use Filament\Infolists;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Tabs;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Schemas\Schema;
|
||||
@ -106,6 +110,15 @@ public static function infolist(Schema $schema): Schema
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
|
||||
Infolists\Components\TextEntry::make('created_by')->label('Actor'),
|
||||
Infolists\Components\TextEntry::make('captured_at')->dateTime(),
|
||||
Section::make('Related context')
|
||||
->schema([
|
||||
Infolists\Components\ViewEntry::make('related_context')
|
||||
->label('')
|
||||
->view('filament.infolists.entries.related-context')
|
||||
->state(fn (PolicyVersion $record): array => static::relatedContextEntries($record))
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
Tabs::make()
|
||||
->activeTab(1)
|
||||
->persistTabInQueryString('tab')
|
||||
@ -522,6 +535,7 @@ public static function table(Table $table): Table
|
||||
? static::getUrl('view', ['record' => $record])
|
||||
: null)
|
||||
->actions([
|
||||
static::primaryRelatedAction(),
|
||||
Actions\ActionGroup::make([
|
||||
(function (): Actions\Action {
|
||||
$action = Actions\Action::make('restore_via_wizard')
|
||||
@ -889,6 +903,42 @@ public static function getEloquentQuery(): Builder
|
||||
->with('policy');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{
|
||||
* key: string,
|
||||
* label: string,
|
||||
* value: string,
|
||||
* secondaryValue: ?string,
|
||||
* targetUrl: ?string,
|
||||
* targetKind: string,
|
||||
* availability: string,
|
||||
* unavailableReason: ?string,
|
||||
* contextBadge: ?string,
|
||||
* priority: int,
|
||||
* actionLabel: string
|
||||
* }>
|
||||
*/
|
||||
public static function relatedContextEntries(PolicyVersion $record): array
|
||||
{
|
||||
return app(RelatedNavigationResolver::class)
|
||||
->detailEntries(CrossResourceNavigationMatrix::SOURCE_POLICY_VERSION, $record);
|
||||
}
|
||||
|
||||
private static function primaryRelatedAction(): Actions\Action
|
||||
{
|
||||
return Actions\Action::make('primary_drill_down')
|
||||
->label(fn (PolicyVersion $record): string => static::primaryRelatedEntry($record)?->actionLabel ?? 'Open related record')
|
||||
->url(fn (PolicyVersion $record): ?string => static::primaryRelatedEntry($record)?->targetUrl)
|
||||
->hidden(fn (PolicyVersion $record): bool => ! (static::primaryRelatedEntry($record)?->isAvailable() ?? false))
|
||||
->color('gray');
|
||||
}
|
||||
|
||||
private static function primaryRelatedEntry(PolicyVersion $record): ?RelatedContextEntry
|
||||
{
|
||||
return app(RelatedNavigationResolver::class)
|
||||
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_POLICY_VERSION, $record);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
|
||||
@ -3,6 +3,9 @@
|
||||
namespace App\Filament\Resources\PolicyVersionResource\Pages;
|
||||
|
||||
use App\Filament\Resources\PolicyVersionResource;
|
||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||
use App\Support\Navigation\RelatedNavigationResolver;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Filament\Support\Enums\Width;
|
||||
use Illuminate\Contracts\View\View;
|
||||
@ -13,6 +16,20 @@ class ViewPolicyVersion extends ViewRecord
|
||||
|
||||
protected Width|string|null $maxContentWidth = Width::Full;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('primary_related')
|
||||
->label(fn (): string => app(RelatedNavigationResolver::class)
|
||||
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_POLICY_VERSION, $this->getRecord())?->actionLabel ?? 'Open related record')
|
||||
->url(fn (): ?string => app(RelatedNavigationResolver::class)
|
||||
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_POLICY_VERSION, $this->getRecord())?->targetUrl)
|
||||
->hidden(fn (): bool => ! (app(RelatedNavigationResolver::class)
|
||||
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_POLICY_VERSION, $this->getRecord())?->isAvailable() ?? false))
|
||||
->color('gray'),
|
||||
];
|
||||
}
|
||||
|
||||
public function getFooter(): ?View
|
||||
{
|
||||
return view('filament.resources.policy-version-resource.pages.view-policy-version-footer', [
|
||||
|
||||
66
app/Support/Navigation/CanonicalNavigationContext.php
Normal file
66
app/Support/Navigation/CanonicalNavigationContext.php
Normal file
@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Navigation;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final readonly class CanonicalNavigationContext
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $filterPayload
|
||||
*/
|
||||
public function __construct(
|
||||
public string $sourceSurface,
|
||||
public string $canonicalRouteName,
|
||||
public ?int $tenantId = null,
|
||||
public ?string $backLinkLabel = null,
|
||||
public ?string $backLinkUrl = null,
|
||||
public array $filterPayload = [],
|
||||
) {}
|
||||
|
||||
public static function fromRequest(Request $request): ?self
|
||||
{
|
||||
$payload = $request->query('nav');
|
||||
|
||||
if (! is_array($payload)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$sourceSurface = $payload['source_surface'] ?? null;
|
||||
$canonicalRouteName = $payload['canonical_route_name'] ?? null;
|
||||
|
||||
if (! is_string($sourceSurface) || $sourceSurface === '' || ! is_string($canonicalRouteName) || $canonicalRouteName === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tenantId = $payload['tenant_id'] ?? null;
|
||||
|
||||
return new self(
|
||||
sourceSurface: $sourceSurface,
|
||||
canonicalRouteName: $canonicalRouteName,
|
||||
tenantId: is_numeric($tenantId) ? (int) $tenantId : null,
|
||||
backLinkLabel: is_string($payload['back_label'] ?? null) ? (string) $payload['back_label'] : null,
|
||||
backLinkUrl: is_string($payload['back_url'] ?? null) ? (string) $payload['back_url'] : null,
|
||||
filterPayload: [],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toQuery(): array
|
||||
{
|
||||
$query = $this->filterPayload;
|
||||
$query['nav'] = array_filter([
|
||||
'source_surface' => $this->sourceSurface,
|
||||
'canonical_route_name' => $this->canonicalRouteName,
|
||||
'tenant_id' => $this->tenantId,
|
||||
'back_label' => $this->backLinkLabel,
|
||||
'back_url' => $this->backLinkUrl,
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== '');
|
||||
|
||||
return $query;
|
||||
}
|
||||
}
|
||||
82
app/Support/Navigation/CrossResourceNavigationMatrix.php
Normal file
82
app/Support/Navigation/CrossResourceNavigationMatrix.php
Normal file
@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Navigation;
|
||||
|
||||
final class CrossResourceNavigationMatrix
|
||||
{
|
||||
public const string SURFACE_DETAIL_SECTION = 'detail_section';
|
||||
|
||||
public const string SURFACE_DETAIL_HEADER = 'detail_header';
|
||||
|
||||
public const string SURFACE_LIST_ROW = 'list_row';
|
||||
|
||||
public const string SOURCE_BACKUP_SET = 'backup_set';
|
||||
|
||||
public const string SOURCE_BASELINE_PROFILE = 'baseline_profile';
|
||||
|
||||
public const string SOURCE_BASELINE_SNAPSHOT = 'baseline_snapshot';
|
||||
|
||||
public const string SOURCE_FINDING = 'finding';
|
||||
|
||||
public const string SOURCE_OPERATION_RUN = 'operation_run';
|
||||
|
||||
public const string SOURCE_POLICY_VERSION = 'policy_version';
|
||||
|
||||
/**
|
||||
* @return list<NavigationMatrixRule>
|
||||
*/
|
||||
public function rulesFor(string $sourceType, string $sourceSurface): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
$this->rules(),
|
||||
static fn (NavigationMatrixRule $rule): bool => $rule->sourceType === $sourceType
|
||||
&& $rule->sourceSurface === $sourceSurface,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<NavigationMatrixRule>
|
||||
*/
|
||||
private function rules(): array
|
||||
{
|
||||
return [
|
||||
new NavigationMatrixRule(self::SOURCE_FINDING, self::SURFACE_DETAIL_SECTION, 'baseline_snapshot', 'baseline_snapshot', 'direct_record', 10, missingStatePolicy: 'show_reference_only'),
|
||||
new NavigationMatrixRule(self::SOURCE_FINDING, self::SURFACE_DETAIL_SECTION, 'source_run', 'operation_run', 'canonical_page', 20, missingStatePolicy: 'show_reference_only'),
|
||||
new NavigationMatrixRule(self::SOURCE_FINDING, self::SURFACE_DETAIL_SECTION, 'current_policy_version', 'policy_version', 'direct_record', 30, missingStatePolicy: 'show_reference_only'),
|
||||
new NavigationMatrixRule(self::SOURCE_FINDING, self::SURFACE_DETAIL_SECTION, 'parent_policy', 'policy', 'direct_record', 40, missingStatePolicy: 'hide'),
|
||||
new NavigationMatrixRule(self::SOURCE_FINDING, self::SURFACE_DETAIL_SECTION, 'baseline_profile', 'baseline_profile', 'direct_record', 50, missingStatePolicy: 'show_reference_only'),
|
||||
new NavigationMatrixRule(self::SOURCE_FINDING, self::SURFACE_LIST_ROW, 'baseline_snapshot', 'baseline_snapshot', 'direct_record', 10, missingStatePolicy: 'hide'),
|
||||
new NavigationMatrixRule(self::SOURCE_FINDING, self::SURFACE_LIST_ROW, 'source_run', 'operation_run', 'canonical_page', 20, missingStatePolicy: 'hide'),
|
||||
|
||||
new NavigationMatrixRule(self::SOURCE_POLICY_VERSION, self::SURFACE_DETAIL_SECTION, 'parent_policy', 'policy', 'direct_record', 10, missingStatePolicy: 'show_reference_only'),
|
||||
new NavigationMatrixRule(self::SOURCE_POLICY_VERSION, self::SURFACE_DETAIL_SECTION, 'baseline_snapshot', 'baseline_snapshot', 'direct_record', 20, missingStatePolicy: 'hide'),
|
||||
new NavigationMatrixRule(self::SOURCE_POLICY_VERSION, self::SURFACE_DETAIL_SECTION, 'baseline_profile', 'baseline_profile', 'direct_record', 30, missingStatePolicy: 'hide'),
|
||||
new NavigationMatrixRule(self::SOURCE_POLICY_VERSION, self::SURFACE_DETAIL_SECTION, 'source_run', 'operation_run', 'canonical_page', 40, missingStatePolicy: 'hide'),
|
||||
new NavigationMatrixRule(self::SOURCE_POLICY_VERSION, self::SURFACE_LIST_ROW, 'parent_policy', 'policy', 'direct_record', 10, missingStatePolicy: 'hide'),
|
||||
new NavigationMatrixRule(self::SOURCE_POLICY_VERSION, self::SURFACE_LIST_ROW, 'baseline_snapshot', 'baseline_snapshot', 'direct_record', 20, missingStatePolicy: 'hide'),
|
||||
|
||||
new NavigationMatrixRule(self::SOURCE_BASELINE_SNAPSHOT, self::SURFACE_DETAIL_SECTION, 'baseline_profile', 'baseline_profile', 'direct_record', 10, missingStatePolicy: 'show_reference_only'),
|
||||
new NavigationMatrixRule(self::SOURCE_BASELINE_SNAPSHOT, self::SURFACE_DETAIL_SECTION, 'source_run', 'operation_run', 'canonical_page', 20, missingStatePolicy: 'hide'),
|
||||
new NavigationMatrixRule(self::SOURCE_BASELINE_SNAPSHOT, self::SURFACE_DETAIL_SECTION, 'policy_version', 'policy_version', 'direct_record', 30, missingStatePolicy: 'hide'),
|
||||
new NavigationMatrixRule(self::SOURCE_BASELINE_SNAPSHOT, self::SURFACE_DETAIL_HEADER, 'baseline_profile', 'baseline_profile', 'direct_record', 10, missingStatePolicy: 'hide'),
|
||||
new NavigationMatrixRule(self::SOURCE_BASELINE_SNAPSHOT, self::SURFACE_DETAIL_HEADER, 'source_run', 'operation_run', 'canonical_page', 20, missingStatePolicy: 'hide'),
|
||||
new NavigationMatrixRule(self::SOURCE_BASELINE_SNAPSHOT, self::SURFACE_LIST_ROW, 'baseline_profile', 'baseline_profile', 'direct_record', 10, missingStatePolicy: 'hide'),
|
||||
|
||||
new NavigationMatrixRule(self::SOURCE_BACKUP_SET, self::SURFACE_DETAIL_SECTION, 'source_run', 'operation_run', 'canonical_page', 10, missingStatePolicy: 'hide'),
|
||||
new NavigationMatrixRule(self::SOURCE_BACKUP_SET, self::SURFACE_DETAIL_SECTION, 'operations', 'operations', 'canonical_page', 20, missingStatePolicy: 'hide'),
|
||||
new NavigationMatrixRule(self::SOURCE_BACKUP_SET, self::SURFACE_LIST_ROW, 'source_run', 'operation_run', 'canonical_page', 10, missingStatePolicy: 'hide'),
|
||||
new NavigationMatrixRule(self::SOURCE_BACKUP_SET, self::SURFACE_LIST_ROW, 'operations', 'operations', 'canonical_page', 20, missingStatePolicy: 'hide'),
|
||||
|
||||
new NavigationMatrixRule(self::SOURCE_OPERATION_RUN, self::SURFACE_DETAIL_SECTION, 'backup_set', 'backup_set', 'direct_record', 10, missingStatePolicy: 'hide'),
|
||||
new NavigationMatrixRule(self::SOURCE_OPERATION_RUN, self::SURFACE_DETAIL_SECTION, 'restore_run', 'restore_run', 'direct_record', 20, missingStatePolicy: 'hide'),
|
||||
new NavigationMatrixRule(self::SOURCE_OPERATION_RUN, self::SURFACE_DETAIL_SECTION, 'baseline_profile', 'baseline_profile', 'direct_record', 30, missingStatePolicy: 'hide'),
|
||||
new NavigationMatrixRule(self::SOURCE_OPERATION_RUN, self::SURFACE_DETAIL_SECTION, 'baseline_snapshot', 'baseline_snapshot', 'direct_record', 40, missingStatePolicy: 'hide'),
|
||||
new NavigationMatrixRule(self::SOURCE_OPERATION_RUN, self::SURFACE_DETAIL_SECTION, 'parent_policy', 'policy', 'direct_record', 50, missingStatePolicy: 'hide'),
|
||||
new NavigationMatrixRule(self::SOURCE_OPERATION_RUN, self::SURFACE_DETAIL_SECTION, 'operations', 'operations', 'canonical_page', 60, missingStatePolicy: 'hide'),
|
||||
|
||||
new NavigationMatrixRule(self::SOURCE_BASELINE_PROFILE, self::SURFACE_DETAIL_HEADER, 'baseline_snapshot', 'baseline_snapshot', 'direct_record', 10, missingStatePolicy: 'hide'),
|
||||
];
|
||||
}
|
||||
}
|
||||
19
app/Support/Navigation/NavigationMatrixRule.php
Normal file
19
app/Support/Navigation/NavigationMatrixRule.php
Normal file
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Navigation;
|
||||
|
||||
final readonly class NavigationMatrixRule
|
||||
{
|
||||
public function __construct(
|
||||
public string $sourceType,
|
||||
public string $sourceSurface,
|
||||
public string $relationKey,
|
||||
public string $targetType,
|
||||
public string $targetMode,
|
||||
public int $priority,
|
||||
public bool $requiresCapabilityCheck = true,
|
||||
public string $missingStatePolicy = 'hide',
|
||||
) {}
|
||||
}
|
||||
61
app/Support/Navigation/RelatedActionLabelCatalog.php
Normal file
61
app/Support/Navigation/RelatedActionLabelCatalog.php
Normal file
@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Navigation;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class RelatedActionLabelCatalog
|
||||
{
|
||||
/**
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private const ENTRY_LABELS = [
|
||||
'baseline_profile' => 'Baseline profile',
|
||||
'baseline_snapshot' => 'Snapshot',
|
||||
'backup_set' => 'Backup set',
|
||||
'current_policy_version' => 'Current policy version',
|
||||
'operations' => 'Operations',
|
||||
'parent_policy' => 'Policy',
|
||||
'policy_version' => 'Policy version',
|
||||
'restore_run' => 'Restore run',
|
||||
'source_run' => 'Run',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private const ACTION_LABELS = [
|
||||
'baseline_profile' => 'View baseline profile',
|
||||
'baseline_snapshot' => 'View snapshot',
|
||||
'backup_set' => 'View backup set',
|
||||
'current_policy_version' => 'View policy version',
|
||||
'operations' => 'Open operations',
|
||||
'parent_policy' => 'View policy',
|
||||
'policy_version' => 'View policy version',
|
||||
'restore_run' => 'View restore run',
|
||||
'source_run' => 'View run',
|
||||
];
|
||||
|
||||
public function entryLabel(string $relationKey): string
|
||||
{
|
||||
return self::ENTRY_LABELS[$relationKey] ?? Str::headline(str_replace('_', ' ', $relationKey));
|
||||
}
|
||||
|
||||
public function actionLabel(string $relationKey): string
|
||||
{
|
||||
return self::ACTION_LABELS[$relationKey] ?? 'Open '.Str::headline(str_replace('_', ' ', $relationKey));
|
||||
}
|
||||
|
||||
public function unavailableMessage(string $relationKey, string $reason): string
|
||||
{
|
||||
$subject = mb_strtolower($this->entryLabel($relationKey));
|
||||
|
||||
return match ($reason) {
|
||||
'missing', 'deleted' => "The related {$subject} is no longer available.",
|
||||
'unauthorized' => "The related {$subject} is not available in the current scope.",
|
||||
default => "The related {$subject} could not be resolved.",
|
||||
};
|
||||
}
|
||||
}
|
||||
108
app/Support/Navigation/RelatedContextEntry.php
Normal file
108
app/Support/Navigation/RelatedContextEntry.php
Normal file
@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Navigation;
|
||||
|
||||
final readonly class RelatedContextEntry
|
||||
{
|
||||
public function __construct(
|
||||
public string $key,
|
||||
public string $label,
|
||||
public string $value,
|
||||
public ?string $secondaryValue,
|
||||
public ?string $targetUrl,
|
||||
public string $targetKind,
|
||||
public string $availability,
|
||||
public ?string $unavailableReason,
|
||||
public ?string $contextBadge,
|
||||
public int $priority,
|
||||
public string $actionLabel,
|
||||
) {}
|
||||
|
||||
public static function available(
|
||||
string $key,
|
||||
string $label,
|
||||
string $value,
|
||||
?string $secondaryValue,
|
||||
string $targetUrl,
|
||||
string $targetKind,
|
||||
int $priority,
|
||||
string $actionLabel,
|
||||
?string $contextBadge = null,
|
||||
): self {
|
||||
return new self(
|
||||
key: $key,
|
||||
label: $label,
|
||||
value: $value,
|
||||
secondaryValue: $secondaryValue,
|
||||
targetUrl: $targetUrl,
|
||||
targetKind: $targetKind,
|
||||
availability: 'available',
|
||||
unavailableReason: null,
|
||||
contextBadge: $contextBadge,
|
||||
priority: $priority,
|
||||
actionLabel: $actionLabel,
|
||||
);
|
||||
}
|
||||
|
||||
public static function unavailable(
|
||||
string $key,
|
||||
string $label,
|
||||
UnavailableRelationState $state,
|
||||
string $targetKind,
|
||||
int $priority,
|
||||
string $actionLabel,
|
||||
): self {
|
||||
return new self(
|
||||
key: $key,
|
||||
label: $label,
|
||||
value: 'Unavailable',
|
||||
secondaryValue: $state->showReference ? $state->referenceValue : null,
|
||||
targetUrl: null,
|
||||
targetKind: $targetKind,
|
||||
availability: $state->reason,
|
||||
unavailableReason: $state->message,
|
||||
contextBadge: null,
|
||||
priority: $priority,
|
||||
actionLabel: $actionLabel,
|
||||
);
|
||||
}
|
||||
|
||||
public function isAvailable(): bool
|
||||
{
|
||||
return $this->availability === 'available' && is_string($this->targetUrl) && $this->targetUrl !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* key: string,
|
||||
* label: string,
|
||||
* value: string,
|
||||
* secondaryValue: ?string,
|
||||
* targetUrl: ?string,
|
||||
* targetKind: string,
|
||||
* availability: string,
|
||||
* unavailableReason: ?string,
|
||||
* contextBadge: ?string,
|
||||
* priority: int,
|
||||
* actionLabel: string
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'key' => $this->key,
|
||||
'label' => $this->label,
|
||||
'value' => $this->value,
|
||||
'secondaryValue' => $this->secondaryValue,
|
||||
'targetUrl' => $this->targetUrl,
|
||||
'targetKind' => $this->targetKind,
|
||||
'availability' => $this->availability,
|
||||
'unavailableReason' => $this->unavailableReason,
|
||||
'contextBadge' => $this->contextBadge,
|
||||
'priority' => $this->priority,
|
||||
'actionLabel' => $this->actionLabel,
|
||||
];
|
||||
}
|
||||
}
|
||||
898
app/Support/Navigation/RelatedNavigationResolver.php
Normal file
898
app/Support/Navigation/RelatedNavigationResolver.php
Normal file
@ -0,0 +1,898 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Navigation;
|
||||
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Filament\Resources\BaselineProfileResource;
|
||||
use App\Filament\Resources\BaselineSnapshotResource;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Filament\Resources\PolicyResource;
|
||||
use App\Filament\Resources\PolicyVersionResource;
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineSnapshotItem;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunLinks;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
final class RelatedNavigationResolver
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CrossResourceNavigationMatrix $matrix,
|
||||
private readonly RelatedActionLabelCatalog $labels,
|
||||
private readonly CapabilityResolver $capabilityResolver,
|
||||
private readonly WorkspaceCapabilityResolver $workspaceCapabilityResolver,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return list<array{
|
||||
* key: string,
|
||||
* label: string,
|
||||
* value: string,
|
||||
* secondaryValue: ?string,
|
||||
* targetUrl: ?string,
|
||||
* targetKind: string,
|
||||
* availability: string,
|
||||
* unavailableReason: ?string,
|
||||
* contextBadge: ?string,
|
||||
* priority: int,
|
||||
* actionLabel: string
|
||||
* }>
|
||||
*/
|
||||
public function detailEntries(string $sourceType, Model $record): array
|
||||
{
|
||||
return array_map(
|
||||
static fn (RelatedContextEntry $entry): array => $entry->toArray(),
|
||||
$this->resolveEntries($sourceType, CrossResourceNavigationMatrix::SURFACE_DETAIL_SECTION, $record),
|
||||
);
|
||||
}
|
||||
|
||||
public function primaryListAction(string $sourceType, Model $record): ?RelatedContextEntry
|
||||
{
|
||||
$entries = array_values(array_filter(
|
||||
$this->resolveEntries($sourceType, CrossResourceNavigationMatrix::SURFACE_LIST_ROW, $record),
|
||||
static fn (RelatedContextEntry $entry): bool => $entry->isAvailable(),
|
||||
));
|
||||
|
||||
return $entries[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function operationLinks(OperationRun $run, ?Tenant $tenant): array
|
||||
{
|
||||
$entries = array_filter(
|
||||
$this->resolveEntries(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, CrossResourceNavigationMatrix::SURFACE_DETAIL_SECTION, $run),
|
||||
static fn (RelatedContextEntry $entry): bool => $entry->isAvailable(),
|
||||
);
|
||||
|
||||
$links = [];
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
$links[$entry->actionLabel] = (string) $entry->targetUrl;
|
||||
}
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
$links = ['Open operations' => OperationRunLinks::index($tenant)] + $links;
|
||||
} else {
|
||||
$links = ['Open operations' => OperationRunLinks::index()] + $links;
|
||||
}
|
||||
|
||||
return $links;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<RelatedContextEntry>
|
||||
*/
|
||||
public function headerEntries(string $sourceType, Model $record): array
|
||||
{
|
||||
return $this->resolveEntries($sourceType, CrossResourceNavigationMatrix::SURFACE_DETAIL_HEADER, $record);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<RelatedContextEntry>
|
||||
*/
|
||||
private function resolveEntries(string $sourceType, string $surface, Model $record): array
|
||||
{
|
||||
$entries = [];
|
||||
|
||||
foreach ($this->matrix->rulesFor($sourceType, $surface) as $rule) {
|
||||
$entry = $this->resolveRule($rule, $record);
|
||||
|
||||
if ($entry instanceof RelatedContextEntry) {
|
||||
$entries[] = $entry;
|
||||
}
|
||||
}
|
||||
|
||||
usort(
|
||||
$entries,
|
||||
static fn (RelatedContextEntry $left, RelatedContextEntry $right): int => $left->priority <=> $right->priority,
|
||||
);
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
private function resolveRule(NavigationMatrixRule $rule, Model $record): ?RelatedContextEntry
|
||||
{
|
||||
return match ($rule->sourceType) {
|
||||
CrossResourceNavigationMatrix::SOURCE_FINDING => $record instanceof Finding ? $this->resolveFindingRule($rule, $record) : null,
|
||||
CrossResourceNavigationMatrix::SOURCE_POLICY_VERSION => $record instanceof PolicyVersion ? $this->resolvePolicyVersionRule($rule, $record) : null,
|
||||
CrossResourceNavigationMatrix::SOURCE_BASELINE_SNAPSHOT => $record instanceof BaselineSnapshot ? $this->resolveBaselineSnapshotRule($rule, $record) : null,
|
||||
CrossResourceNavigationMatrix::SOURCE_BACKUP_SET => $record instanceof BackupSet ? $this->resolveBackupSetRule($rule, $record) : null,
|
||||
CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN => $record instanceof OperationRun ? $this->resolveOperationRunRule($rule, $record) : null,
|
||||
CrossResourceNavigationMatrix::SOURCE_BASELINE_PROFILE => $record instanceof BaselineProfile ? $this->resolveBaselineProfileRule($rule, $record) : null,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function resolveFindingRule(NavigationMatrixRule $rule, Finding $finding): ?RelatedContextEntry
|
||||
{
|
||||
return match ($rule->relationKey) {
|
||||
'baseline_snapshot' => $this->baselineSnapshotEntry(
|
||||
rule: $rule,
|
||||
snapshotId: $this->findingSnapshotId($finding),
|
||||
workspaceId: (int) $finding->workspace_id,
|
||||
),
|
||||
'source_run' => $this->operationRunEntry(
|
||||
rule: $rule,
|
||||
runId: $this->findingRunId($finding),
|
||||
workspaceId: (int) $finding->workspace_id,
|
||||
context: $this->contextForFinding($finding, $rule->sourceSurface),
|
||||
),
|
||||
'current_policy_version' => $this->policyVersionEntry(
|
||||
rule: $rule,
|
||||
policyVersionId: $this->findingPolicyVersionId($finding),
|
||||
tenantId: (int) $finding->tenant_id,
|
||||
),
|
||||
'parent_policy' => $this->parentPolicyEntryForFinding($rule, $finding),
|
||||
'baseline_profile' => $this->baselineProfileEntry(
|
||||
rule: $rule,
|
||||
profileId: $this->findingProfileId($finding),
|
||||
workspaceId: (int) $finding->workspace_id,
|
||||
),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function resolvePolicyVersionRule(NavigationMatrixRule $rule, PolicyVersion $version): ?RelatedContextEntry
|
||||
{
|
||||
return match ($rule->relationKey) {
|
||||
'parent_policy' => $this->policyEntry(
|
||||
rule: $rule,
|
||||
policy: $version->policy,
|
||||
),
|
||||
'baseline_snapshot' => $this->policyVersionSnapshotEntry($rule, $version),
|
||||
'baseline_profile' => $this->baselineProfileEntry(
|
||||
rule: $rule,
|
||||
profileId: is_numeric($version->baseline_profile_id ?? null) ? (int) $version->baseline_profile_id : null,
|
||||
workspaceId: (int) $version->workspace_id,
|
||||
),
|
||||
'source_run' => $this->operationRunEntry(
|
||||
rule: $rule,
|
||||
runId: is_numeric($version->operation_run_id ?? null) ? (int) $version->operation_run_id : null,
|
||||
workspaceId: (int) $version->workspace_id,
|
||||
context: $this->contextForPolicyVersion($version, $rule->sourceSurface),
|
||||
),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function resolveBaselineSnapshotRule(NavigationMatrixRule $rule, BaselineSnapshot $snapshot): ?RelatedContextEntry
|
||||
{
|
||||
return match ($rule->relationKey) {
|
||||
'baseline_profile' => $this->policyProfileEntry(
|
||||
rule: $rule,
|
||||
profile: $snapshot->baselineProfile,
|
||||
),
|
||||
'source_run' => $this->snapshotRunEntry($rule, $snapshot),
|
||||
'policy_version' => $this->snapshotPolicyVersionEntry($rule, $snapshot),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function resolveBackupSetRule(NavigationMatrixRule $rule, BackupSet $backupSet): ?RelatedContextEntry
|
||||
{
|
||||
return match ($rule->relationKey) {
|
||||
'source_run' => $this->backupSetRunEntry($rule, $backupSet),
|
||||
'operations' => $this->operationsEntry(
|
||||
rule: $rule,
|
||||
tenant: $backupSet->tenant,
|
||||
context: $this->contextForBackupSet($backupSet, $rule->sourceSurface),
|
||||
),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function resolveOperationRunRule(NavigationMatrixRule $rule, OperationRun $run): ?RelatedContextEntry
|
||||
{
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
|
||||
return match ($rule->relationKey) {
|
||||
'backup_set' => $this->backupSetEntry(
|
||||
rule: $rule,
|
||||
backupSetId: is_numeric($context['backup_set_id'] ?? null) ? (int) $context['backup_set_id'] : null,
|
||||
tenantId: is_numeric($run->tenant_id ?? null) ? (int) $run->tenant_id : null,
|
||||
),
|
||||
'restore_run' => $this->restoreRunEntry(
|
||||
rule: $rule,
|
||||
restoreRunId: is_numeric($context['restore_run_id'] ?? null) ? (int) $context['restore_run_id'] : null,
|
||||
tenantId: is_numeric($run->tenant_id ?? null) ? (int) $run->tenant_id : null,
|
||||
),
|
||||
'baseline_profile' => $this->baselineProfileEntry(
|
||||
rule: $rule,
|
||||
profileId: is_numeric($context['baseline_profile_id'] ?? null) ? (int) $context['baseline_profile_id'] : null,
|
||||
workspaceId: (int) $run->workspace_id,
|
||||
),
|
||||
'baseline_snapshot' => $this->baselineSnapshotEntry(
|
||||
rule: $rule,
|
||||
snapshotId: is_numeric($context['baseline_snapshot_id'] ?? null) ? (int) $context['baseline_snapshot_id'] : null,
|
||||
workspaceId: (int) $run->workspace_id,
|
||||
),
|
||||
'parent_policy' => $this->operationRunPolicyEntry($rule, $run),
|
||||
'operations' => $this->operationsEntry(
|
||||
rule: $rule,
|
||||
tenant: $run->tenant,
|
||||
context: $this->contextForOperationRun($run),
|
||||
),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function resolveBaselineProfileRule(NavigationMatrixRule $rule, BaselineProfile $profile): ?RelatedContextEntry
|
||||
{
|
||||
return match ($rule->relationKey) {
|
||||
'baseline_snapshot' => $this->baselineSnapshotEntry(
|
||||
rule: $rule,
|
||||
snapshotId: is_numeric($profile->active_snapshot_id ?? null) ? (int) $profile->active_snapshot_id : null,
|
||||
workspaceId: (int) $profile->workspace_id,
|
||||
),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function findingSnapshotId(Finding $finding): ?int
|
||||
{
|
||||
$snapshotId = Arr::get($finding->evidence_jsonb ?? [], 'provenance.baseline_snapshot_id');
|
||||
|
||||
if (! is_numeric($snapshotId)) {
|
||||
$snapshotId = Arr::get($finding->evidence_jsonb ?? [], 'baseline_snapshot_id');
|
||||
}
|
||||
|
||||
return is_numeric($snapshotId) ? (int) $snapshotId : null;
|
||||
}
|
||||
|
||||
private function findingProfileId(Finding $finding): ?int
|
||||
{
|
||||
$profileId = Arr::get($finding->evidence_jsonb ?? [], 'provenance.baseline_profile_id');
|
||||
|
||||
return is_numeric($profileId) ? (int) $profileId : null;
|
||||
}
|
||||
|
||||
private function findingPolicyVersionId(Finding $finding): ?int
|
||||
{
|
||||
$policyVersionId = Arr::get($finding->evidence_jsonb ?? [], 'current.policy_version_id');
|
||||
|
||||
if (! is_numeric($policyVersionId)) {
|
||||
$policyVersionId = Arr::get($finding->evidence_jsonb ?? [], 'baseline.policy_version_id');
|
||||
}
|
||||
|
||||
return is_numeric($policyVersionId) ? (int) $policyVersionId : null;
|
||||
}
|
||||
|
||||
private function findingRunId(Finding $finding): ?int
|
||||
{
|
||||
$runId = Arr::get($finding->evidence_jsonb ?? [], 'provenance.compare_operation_run_id');
|
||||
|
||||
if (is_numeric($runId)) {
|
||||
return (int) $runId;
|
||||
}
|
||||
|
||||
if (is_numeric($finding->current_operation_run_id ?? null)) {
|
||||
return (int) $finding->current_operation_run_id;
|
||||
}
|
||||
|
||||
return is_numeric($finding->baseline_operation_run_id ?? null)
|
||||
? (int) $finding->baseline_operation_run_id
|
||||
: null;
|
||||
}
|
||||
|
||||
private function policyVersionSnapshotEntry(NavigationMatrixRule $rule, PolicyVersion $version): ?RelatedContextEntry
|
||||
{
|
||||
$snapshotItem = BaselineSnapshotItem::query()
|
||||
->where('meta_jsonb->version_reference->policy_version_id', (int) $version->getKey())
|
||||
->whereHas('snapshot', fn ($query) => $query->where('workspace_id', (int) $version->workspace_id))
|
||||
->with('snapshot.baselineProfile')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
if (! $snapshotItem instanceof BaselineSnapshotItem) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$snapshot = $snapshotItem->snapshot;
|
||||
|
||||
return $snapshot instanceof BaselineSnapshot
|
||||
? $this->baselineSnapshotEntry($rule, (int) $snapshot->getKey(), (int) $version->workspace_id)
|
||||
: null;
|
||||
}
|
||||
|
||||
private function snapshotRunEntry(NavigationMatrixRule $rule, BaselineSnapshot $snapshot): ?RelatedContextEntry
|
||||
{
|
||||
$candidate = OperationRun::query()
|
||||
->where('workspace_id', (int) $snapshot->workspace_id)
|
||||
->where('context->baseline_snapshot_id', (int) $snapshot->getKey())
|
||||
->orderByDesc('completed_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
return $candidate instanceof OperationRun
|
||||
? $this->operationRunEntry(
|
||||
rule: $rule,
|
||||
runId: (int) $candidate->getKey(),
|
||||
workspaceId: (int) $snapshot->workspace_id,
|
||||
context: $this->contextForBaselineSnapshot($snapshot, $rule->sourceSurface),
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
||||
private function snapshotPolicyVersionEntry(NavigationMatrixRule $rule, BaselineSnapshot $snapshot): ?RelatedContextEntry
|
||||
{
|
||||
$snapshotItem = BaselineSnapshotItem::query()
|
||||
->where('baseline_snapshot_id', (int) $snapshot->getKey())
|
||||
->whereNotNull('meta_jsonb->version_reference->policy_version_id')
|
||||
->orderBy('id')
|
||||
->first();
|
||||
|
||||
if (! $snapshotItem instanceof BaselineSnapshotItem) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$policyVersionId = data_get($snapshotItem->meta_jsonb, 'version_reference.policy_version_id');
|
||||
|
||||
return $this->policyVersionEntry(
|
||||
rule: $rule,
|
||||
policyVersionId: is_numeric($policyVersionId) ? (int) $policyVersionId : null,
|
||||
tenantId: $this->activeTenantId(),
|
||||
);
|
||||
}
|
||||
|
||||
private function backupSetRunEntry(NavigationMatrixRule $rule, BackupSet $backupSet): ?RelatedContextEntry
|
||||
{
|
||||
$candidate = OperationRun::query()
|
||||
->where('tenant_id', (int) $backupSet->tenant_id)
|
||||
->where('context->backup_set_id', (int) $backupSet->getKey())
|
||||
->orderByDesc('completed_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
return $candidate instanceof OperationRun
|
||||
? $this->operationRunEntry(
|
||||
rule: $rule,
|
||||
runId: (int) $candidate->getKey(),
|
||||
workspaceId: (int) $backupSet->workspace_id,
|
||||
context: $this->contextForBackupSet($backupSet, $rule->sourceSurface),
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
||||
private function operationRunPolicyEntry(NavigationMatrixRule $rule, OperationRun $run): ?RelatedContextEntry
|
||||
{
|
||||
$policyId = data_get($run->context, 'policy_id');
|
||||
|
||||
if (! is_numeric($policyId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$policy = Policy::query()
|
||||
->whereKey((int) $policyId)
|
||||
->where('tenant_id', (int) $run->tenant_id)
|
||||
->first();
|
||||
|
||||
return $this->policyEntry($rule, $policy);
|
||||
}
|
||||
|
||||
private function parentPolicyEntryForFinding(NavigationMatrixRule $rule, Finding $finding): ?RelatedContextEntry
|
||||
{
|
||||
$policyVersionId = $this->findingPolicyVersionId($finding);
|
||||
|
||||
if ($policyVersionId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$version = PolicyVersion::query()
|
||||
->with('policy')
|
||||
->whereKey($policyVersionId)
|
||||
->where('tenant_id', (int) $finding->tenant_id)
|
||||
->first();
|
||||
|
||||
if (! $version instanceof PolicyVersion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->policyEntry($rule, $version->policy);
|
||||
}
|
||||
|
||||
private function baselineSnapshotEntry(
|
||||
NavigationMatrixRule $rule,
|
||||
?int $snapshotId,
|
||||
int $workspaceId,
|
||||
): ?RelatedContextEntry {
|
||||
if ($snapshotId === null || $snapshotId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$snapshot = BaselineSnapshot::query()
|
||||
->with('baselineProfile')
|
||||
->whereKey($snapshotId)
|
||||
->where('workspace_id', $workspaceId)
|
||||
->first();
|
||||
|
||||
if (! $snapshot instanceof BaselineSnapshot) {
|
||||
return $this->unavailableEntry($rule, (string) $snapshotId, 'missing');
|
||||
}
|
||||
|
||||
if (! $this->canOpenWorkspaceBaselines($workspaceId)) {
|
||||
return $this->unavailableEntry($rule, '#'.$snapshotId, 'unauthorized');
|
||||
}
|
||||
|
||||
$value = '#'.$snapshot->getKey();
|
||||
$secondaryValue = $snapshot->baselineProfile?->name;
|
||||
|
||||
return RelatedContextEntry::available(
|
||||
key: $rule->relationKey,
|
||||
label: $this->labels->entryLabel($rule->relationKey),
|
||||
value: $value,
|
||||
secondaryValue: $secondaryValue,
|
||||
targetUrl: BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'),
|
||||
targetKind: $rule->targetType,
|
||||
priority: $rule->priority,
|
||||
actionLabel: $this->labels->actionLabel($rule->relationKey),
|
||||
contextBadge: 'Workspace',
|
||||
);
|
||||
}
|
||||
|
||||
private function baselineProfileEntry(
|
||||
NavigationMatrixRule $rule,
|
||||
?int $profileId,
|
||||
int $workspaceId,
|
||||
): ?RelatedContextEntry {
|
||||
if ($profileId === null || $profileId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$profile = BaselineProfile::query()
|
||||
->whereKey($profileId)
|
||||
->where('workspace_id', $workspaceId)
|
||||
->first();
|
||||
|
||||
if (! $profile instanceof BaselineProfile) {
|
||||
return $this->unavailableEntry($rule, (string) $profileId, 'missing');
|
||||
}
|
||||
|
||||
return $this->policyProfileEntry($rule, $profile);
|
||||
}
|
||||
|
||||
private function policyProfileEntry(NavigationMatrixRule $rule, ?BaselineProfile $profile): ?RelatedContextEntry
|
||||
{
|
||||
if (! $profile instanceof BaselineProfile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $this->canOpenWorkspaceBaselines((int) $profile->workspace_id)) {
|
||||
return $this->unavailableEntry($rule, '#'.$profile->getKey(), 'unauthorized');
|
||||
}
|
||||
|
||||
return RelatedContextEntry::available(
|
||||
key: $rule->relationKey,
|
||||
label: $this->labels->entryLabel($rule->relationKey),
|
||||
value: (string) $profile->name,
|
||||
secondaryValue: '#'.$profile->getKey(),
|
||||
targetUrl: BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin'),
|
||||
targetKind: $rule->targetType,
|
||||
priority: $rule->priority,
|
||||
actionLabel: $this->labels->actionLabel($rule->relationKey),
|
||||
contextBadge: 'Workspace',
|
||||
);
|
||||
}
|
||||
|
||||
private function operationRunEntry(
|
||||
NavigationMatrixRule $rule,
|
||||
?int $runId,
|
||||
int $workspaceId,
|
||||
?CanonicalNavigationContext $context = null,
|
||||
): ?RelatedContextEntry {
|
||||
if ($runId === null || $runId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$run = OperationRun::query()
|
||||
->whereKey($runId)
|
||||
->where('workspace_id', $workspaceId)
|
||||
->first();
|
||||
|
||||
if (! $run instanceof OperationRun) {
|
||||
return $this->unavailableEntry($rule, (string) $runId, 'missing');
|
||||
}
|
||||
|
||||
if (! $this->canOpenOperationRun($run)) {
|
||||
return $this->unavailableEntry($rule, '#'.$runId, 'unauthorized');
|
||||
}
|
||||
|
||||
return RelatedContextEntry::available(
|
||||
key: $rule->relationKey,
|
||||
label: $this->labels->entryLabel($rule->relationKey),
|
||||
value: OperationCatalog::label((string) $run->type),
|
||||
secondaryValue: '#'.$run->getKey(),
|
||||
targetUrl: OperationRunLinks::tenantlessView($run, $context),
|
||||
targetKind: $rule->targetType,
|
||||
priority: $rule->priority,
|
||||
actionLabel: $this->labels->actionLabel($rule->relationKey),
|
||||
contextBadge: $run->tenant_id ? 'Tenant context' : 'Workspace',
|
||||
);
|
||||
}
|
||||
|
||||
private function policyVersionEntry(
|
||||
NavigationMatrixRule $rule,
|
||||
?int $policyVersionId,
|
||||
?int $tenantId,
|
||||
): ?RelatedContextEntry {
|
||||
if ($policyVersionId === null || $policyVersionId <= 0 || $tenantId === null || $tenantId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$version = PolicyVersion::query()
|
||||
->with(['policy', 'tenant'])
|
||||
->whereKey($policyVersionId)
|
||||
->where('tenant_id', $tenantId)
|
||||
->first();
|
||||
|
||||
if (! $version instanceof PolicyVersion) {
|
||||
return $this->unavailableEntry($rule, (string) $policyVersionId, 'missing');
|
||||
}
|
||||
|
||||
if (! $this->canOpenPolicyVersion($version)) {
|
||||
return $this->unavailableEntry($rule, '#'.$policyVersionId, 'unauthorized');
|
||||
}
|
||||
|
||||
$value = $version->policy?->display_name ?: 'Policy version';
|
||||
$secondaryValue = 'Version '.(string) $version->version_number;
|
||||
|
||||
return RelatedContextEntry::available(
|
||||
key: $rule->relationKey,
|
||||
label: $this->labels->entryLabel($rule->relationKey),
|
||||
value: $value,
|
||||
secondaryValue: $secondaryValue,
|
||||
targetUrl: PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $version->tenant),
|
||||
targetKind: $rule->targetType,
|
||||
priority: $rule->priority,
|
||||
actionLabel: $this->labels->actionLabel($rule->relationKey),
|
||||
contextBadge: 'Tenant',
|
||||
);
|
||||
}
|
||||
|
||||
private function policyEntry(NavigationMatrixRule $rule, ?Policy $policy): ?RelatedContextEntry
|
||||
{
|
||||
if (! $policy instanceof Policy) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $this->canOpenPolicy($policy)) {
|
||||
return $this->unavailableEntry($rule, '#'.$policy->getKey(), 'unauthorized');
|
||||
}
|
||||
|
||||
return RelatedContextEntry::available(
|
||||
key: $rule->relationKey,
|
||||
label: $this->labels->entryLabel($rule->relationKey),
|
||||
value: (string) ($policy->display_name ?: 'Policy'),
|
||||
secondaryValue: '#'.$policy->getKey(),
|
||||
targetUrl: PolicyResource::getUrl('view', ['record' => $policy], tenant: $policy->tenant),
|
||||
targetKind: $rule->targetType,
|
||||
priority: $rule->priority,
|
||||
actionLabel: $this->labels->actionLabel($rule->relationKey),
|
||||
contextBadge: 'Tenant',
|
||||
);
|
||||
}
|
||||
|
||||
private function backupSetEntry(
|
||||
NavigationMatrixRule $rule,
|
||||
?int $backupSetId,
|
||||
?int $tenantId,
|
||||
): ?RelatedContextEntry {
|
||||
if ($backupSetId === null || $backupSetId <= 0 || $tenantId === null || $tenantId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$backupSet = BackupSet::query()
|
||||
->with('tenant')
|
||||
->whereKey($backupSetId)
|
||||
->where('tenant_id', $tenantId)
|
||||
->first();
|
||||
|
||||
if (! $backupSet instanceof BackupSet) {
|
||||
return $this->unavailableEntry($rule, (string) $backupSetId, 'missing');
|
||||
}
|
||||
|
||||
if (! $this->canOpenTenantRecord($backupSet->tenant, Capabilities::TENANT_VIEW)) {
|
||||
return $this->unavailableEntry($rule, '#'.$backupSetId, 'unauthorized');
|
||||
}
|
||||
|
||||
return RelatedContextEntry::available(
|
||||
key: $rule->relationKey,
|
||||
label: $this->labels->entryLabel($rule->relationKey),
|
||||
value: (string) $backupSet->name,
|
||||
secondaryValue: '#'.$backupSet->getKey(),
|
||||
targetUrl: BackupSetResource::getUrl('view', ['record' => $backupSet], tenant: $backupSet->tenant),
|
||||
targetKind: $rule->targetType,
|
||||
priority: $rule->priority,
|
||||
actionLabel: $this->labels->actionLabel($rule->relationKey),
|
||||
contextBadge: 'Tenant',
|
||||
);
|
||||
}
|
||||
|
||||
private function restoreRunEntry(
|
||||
NavigationMatrixRule $rule,
|
||||
?int $restoreRunId,
|
||||
?int $tenantId,
|
||||
): ?RelatedContextEntry {
|
||||
if ($restoreRunId === null || $restoreRunId <= 0 || $tenantId === null || $tenantId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$restoreRun = RestoreRun::query()
|
||||
->with('tenant')
|
||||
->whereKey($restoreRunId)
|
||||
->where('tenant_id', $tenantId)
|
||||
->first();
|
||||
|
||||
if (! $restoreRun instanceof RestoreRun) {
|
||||
return $this->unavailableEntry($rule, (string) $restoreRunId, 'missing');
|
||||
}
|
||||
|
||||
if (! $this->canOpenTenantRecord($restoreRun->tenant, Capabilities::TENANT_VIEW)) {
|
||||
return $this->unavailableEntry($rule, '#'.$restoreRunId, 'unauthorized');
|
||||
}
|
||||
|
||||
return RelatedContextEntry::available(
|
||||
key: $rule->relationKey,
|
||||
label: $this->labels->entryLabel($rule->relationKey),
|
||||
value: 'Restore run',
|
||||
secondaryValue: '#'.$restoreRun->getKey(),
|
||||
targetUrl: RestoreRunResource::getUrl('view', ['record' => $restoreRun], tenant: $restoreRun->tenant),
|
||||
targetKind: $rule->targetType,
|
||||
priority: $rule->priority,
|
||||
actionLabel: $this->labels->actionLabel($rule->relationKey),
|
||||
contextBadge: 'Tenant',
|
||||
);
|
||||
}
|
||||
|
||||
private function operationsEntry(
|
||||
NavigationMatrixRule $rule,
|
||||
?Tenant $tenant,
|
||||
?CanonicalNavigationContext $context = null,
|
||||
): ?RelatedContextEntry {
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $this->canOpenTenantRecord($tenant, Capabilities::TENANT_VIEW)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return RelatedContextEntry::available(
|
||||
key: $rule->relationKey,
|
||||
label: $this->labels->entryLabel($rule->relationKey),
|
||||
value: 'Operations',
|
||||
secondaryValue: $tenant->name,
|
||||
targetUrl: OperationRunLinks::index($tenant, $context),
|
||||
targetKind: $rule->targetType,
|
||||
priority: $rule->priority,
|
||||
actionLabel: $this->labels->actionLabel($rule->relationKey),
|
||||
contextBadge: 'Tenant context',
|
||||
);
|
||||
}
|
||||
|
||||
private function unavailableEntry(NavigationMatrixRule $rule, ?string $referenceValue, string $reason): ?RelatedContextEntry
|
||||
{
|
||||
if ($rule->missingStatePolicy === 'hide') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return RelatedContextEntry::unavailable(
|
||||
key: $rule->relationKey,
|
||||
label: $this->labels->entryLabel($rule->relationKey),
|
||||
state: new UnavailableRelationState(
|
||||
relationKey: $rule->relationKey,
|
||||
referenceValue: $referenceValue,
|
||||
reason: $reason,
|
||||
message: $this->labels->unavailableMessage($rule->relationKey, $reason),
|
||||
showReference: $rule->missingStatePolicy === 'show_reference_only',
|
||||
),
|
||||
targetKind: $rule->targetType,
|
||||
priority: $rule->priority,
|
||||
actionLabel: $this->labels->actionLabel($rule->relationKey),
|
||||
);
|
||||
}
|
||||
|
||||
private function canOpenOperationRun(OperationRun $run): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
return $user instanceof User && $user->can('view', $run);
|
||||
}
|
||||
|
||||
private function canOpenWorkspaceBaselines(int $workspaceId): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->workspaceCapabilityResolver->isMember($user, $workspace)
|
||||
&& $this->workspaceCapabilityResolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW);
|
||||
}
|
||||
|
||||
private function canOpenTenantRecord(?Tenant $tenant, string $capability): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
return $tenant instanceof Tenant
|
||||
&& $user instanceof User
|
||||
&& $this->capabilityResolver->isMember($user, $tenant)
|
||||
&& $this->capabilityResolver->can($user, $tenant, $capability);
|
||||
}
|
||||
|
||||
private function canOpenPolicyVersion(PolicyVersion $version): bool
|
||||
{
|
||||
$tenant = $version->tenant;
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $this->canOpenTenantRecord($tenant, Capabilities::TENANT_VIEW)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (in_array((string) $version->capture_purpose?->value, ['baseline_capture', 'baseline_compare'], true)) {
|
||||
$user = auth()->user();
|
||||
|
||||
return $user instanceof User
|
||||
&& $this->capabilityResolver->isMember($user, $tenant)
|
||||
&& $this->capabilityResolver->can($user, $tenant, Capabilities::TENANT_SYNC);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function canOpenPolicy(Policy $policy): bool
|
||||
{
|
||||
return $this->canOpenTenantRecord($policy->tenant, Capabilities::TENANT_VIEW);
|
||||
}
|
||||
|
||||
private function contextForFinding(Finding $finding, string $surface): CanonicalNavigationContext
|
||||
{
|
||||
$tenant = $finding->tenant;
|
||||
$backLabel = $surface === CrossResourceNavigationMatrix::SURFACE_LIST_ROW ? 'Back to findings' : 'Back to finding';
|
||||
$backUrl = $surface === CrossResourceNavigationMatrix::SURFACE_LIST_ROW
|
||||
? FindingResource::getUrl('index', tenant: $tenant)
|
||||
: FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant);
|
||||
|
||||
return new CanonicalNavigationContext(
|
||||
sourceSurface: 'finding.'.$surface,
|
||||
canonicalRouteName: 'admin.operations.view',
|
||||
tenantId: $tenant?->getKey(),
|
||||
backLinkLabel: $backLabel,
|
||||
backLinkUrl: $backUrl,
|
||||
filterPayload: $tenant instanceof Tenant ? [
|
||||
'tableFilters' => [
|
||||
'tenant_id' => ['value' => (string) $tenant->getKey()],
|
||||
],
|
||||
] : [],
|
||||
);
|
||||
}
|
||||
|
||||
private function contextForPolicyVersion(PolicyVersion $version, string $surface): CanonicalNavigationContext
|
||||
{
|
||||
$tenant = $version->tenant;
|
||||
$backLabel = $surface === CrossResourceNavigationMatrix::SURFACE_LIST_ROW ? 'Back to policy versions' : 'Back to policy version';
|
||||
$backUrl = $surface === CrossResourceNavigationMatrix::SURFACE_LIST_ROW
|
||||
? PolicyVersionResource::getUrl('index', tenant: $tenant)
|
||||
: PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant);
|
||||
|
||||
return new CanonicalNavigationContext(
|
||||
sourceSurface: 'policy_version.'.$surface,
|
||||
canonicalRouteName: 'admin.operations.view',
|
||||
tenantId: $tenant?->getKey(),
|
||||
backLinkLabel: $backLabel,
|
||||
backLinkUrl: $backUrl,
|
||||
filterPayload: $tenant instanceof Tenant ? [
|
||||
'tableFilters' => [
|
||||
'tenant_id' => ['value' => (string) $tenant->getKey()],
|
||||
],
|
||||
] : [],
|
||||
);
|
||||
}
|
||||
|
||||
private function contextForBaselineSnapshot(BaselineSnapshot $snapshot, string $surface): CanonicalNavigationContext
|
||||
{
|
||||
$backLabel = $surface === CrossResourceNavigationMatrix::SURFACE_LIST_ROW ? 'Back to baseline snapshots' : 'Back to baseline snapshot';
|
||||
$backUrl = $surface === CrossResourceNavigationMatrix::SURFACE_LIST_ROW
|
||||
? BaselineSnapshotResource::getUrl(panel: 'admin')
|
||||
: BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin');
|
||||
|
||||
return new CanonicalNavigationContext(
|
||||
sourceSurface: 'baseline_snapshot.'.$surface,
|
||||
canonicalRouteName: 'admin.operations.view',
|
||||
backLinkLabel: $backLabel,
|
||||
backLinkUrl: $backUrl,
|
||||
);
|
||||
}
|
||||
|
||||
private function contextForBackupSet(BackupSet $backupSet, string $surface): CanonicalNavigationContext
|
||||
{
|
||||
$tenant = $backupSet->tenant;
|
||||
$backLabel = $surface === CrossResourceNavigationMatrix::SURFACE_LIST_ROW ? 'Back to backup sets' : 'Back to backup set';
|
||||
$backUrl = $surface === CrossResourceNavigationMatrix::SURFACE_LIST_ROW
|
||||
? BackupSetResource::getUrl('index', tenant: $tenant)
|
||||
: BackupSetResource::getUrl('view', ['record' => $backupSet], tenant: $tenant);
|
||||
|
||||
return new CanonicalNavigationContext(
|
||||
sourceSurface: 'backup_set.'.$surface,
|
||||
canonicalRouteName: 'admin.operations.view',
|
||||
tenantId: $tenant?->getKey(),
|
||||
backLinkLabel: $backLabel,
|
||||
backLinkUrl: $backUrl,
|
||||
filterPayload: $tenant instanceof Tenant ? [
|
||||
'tableFilters' => [
|
||||
'tenant_id' => ['value' => (string) $tenant->getKey()],
|
||||
],
|
||||
] : [],
|
||||
);
|
||||
}
|
||||
|
||||
private function contextForOperationRun(OperationRun $run): CanonicalNavigationContext
|
||||
{
|
||||
$tenant = $run->tenant;
|
||||
|
||||
return new CanonicalNavigationContext(
|
||||
sourceSurface: 'operation_run.detail_section',
|
||||
canonicalRouteName: 'admin.operations.index',
|
||||
tenantId: $tenant?->getKey(),
|
||||
backLinkLabel: 'Back to operations',
|
||||
backLinkUrl: OperationRunLinks::index($tenant),
|
||||
filterPayload: $tenant instanceof Tenant ? [
|
||||
'tableFilters' => [
|
||||
'tenant_id' => ['value' => (string) $tenant->getKey()],
|
||||
],
|
||||
] : [],
|
||||
);
|
||||
}
|
||||
|
||||
private function activeTenantId(): ?int
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
return $tenant instanceof Tenant ? (int) $tenant->getKey() : null;
|
||||
}
|
||||
}
|
||||
16
app/Support/Navigation/UnavailableRelationState.php
Normal file
16
app/Support/Navigation/UnavailableRelationState.php
Normal file
@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Navigation;
|
||||
|
||||
final readonly class UnavailableRelationState
|
||||
{
|
||||
public function __construct(
|
||||
public string $relationKey,
|
||||
public ?string $referenceValue,
|
||||
public string $reason,
|
||||
public string $message,
|
||||
public bool $showReference = false,
|
||||
) {}
|
||||
}
|
||||
@ -12,24 +12,28 @@
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
|
||||
final class OperationRunLinks
|
||||
{
|
||||
public static function index(?Tenant $tenant = null): string
|
||||
public static function index(?Tenant $tenant = null, ?CanonicalNavigationContext $context = null): string
|
||||
{
|
||||
return route('admin.operations.index');
|
||||
return route('admin.operations.index', $context?->toQuery() ?? []);
|
||||
}
|
||||
|
||||
public static function tenantlessView(OperationRun|int $run): string
|
||||
public static function tenantlessView(OperationRun|int $run, ?CanonicalNavigationContext $context = null): string
|
||||
{
|
||||
$runId = $run instanceof OperationRun ? (int) $run->getKey() : (int) $run;
|
||||
|
||||
return route('admin.operations.view', ['run' => $runId]);
|
||||
return route('admin.operations.view', array_merge(
|
||||
['run' => $runId],
|
||||
$context?->toQuery() ?? [],
|
||||
));
|
||||
}
|
||||
|
||||
public static function view(OperationRun|int $run, Tenant $tenant): string
|
||||
public static function view(OperationRun|int $run, Tenant $tenant, ?CanonicalNavigationContext $context = null): string
|
||||
{
|
||||
return self::tenantlessView($run);
|
||||
return self::tenantlessView($run, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -5,7 +5,7 @@ # Spec Candidates
|
||||
>
|
||||
> **Flow**: Inbox → Qualified → Planned → Spec created → removed from this file
|
||||
|
||||
**Last reviewed**: 2026-03-08
|
||||
**Last reviewed**: 2026-03-10
|
||||
|
||||
---
|
||||
|
||||
@ -99,6 +99,25 @@ ### Dashboard Polish (Enterprise-grade)
|
||||
- **Dependencies**: Baseline governance (101), alerts (099), drift engine (119) stable
|
||||
- **Priority**: medium
|
||||
|
||||
### Operations Naming Harmonization Across Run Types, Catalog, UI, and Audit
|
||||
- **Type**: hardening
|
||||
- **Source**: coding discovery, operations UX consistency review
|
||||
- **Why it matters**: Strategically important for enterprise UX, auditability, and long-term platform consistency. `OperationRun` is becoming a cross-domain execution and monitoring backbone, and the current naming drift will get more expensive as new run types and provider domains are added. This should reduce future naming drift, but it is not a blocker-critical refactor and should not be pulled in as a side quest during small UI changes.
|
||||
- **Problem**: Naming around operations appears historically grown and not consistent enough across `OperationRunType` values, visible run labels, `OperationCatalog` mappings, notifications, audit events, filters, badges, and related UI copy. Internal type names and operator-facing language are not cleanly separated, domain/object/verb ordering is uneven, and small UX fixes risk reinforcing an already inconsistent scheme. If left as-is, new run types for baseline, review, alerts, and additional provider domains will extend the inconsistency instead of converging it.
|
||||
- **Desired outcome**: A later spec should define a clear naming standard for `OperationRunType`, establish an explicit distinction between internal type identifiers and operator-facing labels, and align terminology across runs, notifications, audit text, monitoring views, and operations UI. New run types should have documented naming rules so they can be added without re-opening the vocabulary debate.
|
||||
- **In scope**: Inventory of current operation-related naming surfaces; naming taxonomy for internal identifiers versus visible operator language; conventions for verb/object/domain ordering; alignment rules for `OperationCatalog`, run labels, notifications, audit events, filters, badges, and monitoring UI; forward-looking rules for adding new run types and provider/domain families; a pragmatic migration plan that minimizes churn and preserves audit clarity.
|
||||
- **Out of scope**: Opportunistic mass-refactors during unrelated feature work; immediate renaming of all historical values without a compatibility plan; using a small UI wording issue such as "Sync from Intune" versus "Sync policies" as justification for broad churn; a full operations-domain rearchitecture unless later analysis proves it necessary.
|
||||
- **Trigger / Best time to do this**: Best tackled when multiple new run types are about to land, when `OperationCatalog` / monitoring / operations hub work is already active, when new domains such as Entra or Teams are being integrated, or when a broader UI naming constitution is ready to be enforced technically. This is a good candidate for a planned cleanup window, not an ad hoc refactor.
|
||||
- **Risks if ignored**: Continued terminology drift across UI and audit layers, higher cognitive load for operators, weaker enterprise polish, more brittle label mapping, and more expensive cleanup once additional domains and execution types are established. Audit/event language may diverge further from monitoring language, making cross-surface reasoning harder.
|
||||
- **Suggested direction**: Define stable internal run-type identifiers separately from visible operator labels. Standardize a single naming grammar for operation concepts, including when to lead with verb, object, or domain, and when provider-specific wording is allowed. Apply changes incrementally with compatibility-minded mapping rather than a brachial rename of every historical string. Prefer a staged migration that first defines rules and mapping layers, then updates high-value operator surfaces, and only later addresses legacy internals where justified.
|
||||
- **Readiness level**: Qualified and strategically important, but intentionally deferred. This should be specified before substantially more run types and provider domains are introduced, yet it should not become an immediate side-track or be bundled into minor UI wording fixes.
|
||||
- **Candidate quality**:
|
||||
- Clearly identified cross-cutting problem with architectural and UX impact
|
||||
- Strong future-facing trigger conditions instead of vague "sometime later"
|
||||
- Explicit boundaries to prevent opportunistic churn
|
||||
- Concrete desired outcome without overdesigning the solution
|
||||
- Easy to promote into a full spec once operations-domain work is prioritized
|
||||
|
||||
### Support Intake with Context (MVP)
|
||||
- **Type**: feature
|
||||
- **Source**: Product design, operator feedback
|
||||
|
||||
@ -0,0 +1,72 @@
|
||||
@php
|
||||
$entries = $getState() ?? [];
|
||||
@endphp
|
||||
|
||||
<div class="space-y-3">
|
||||
@if ($entries === [])
|
||||
<div class="rounded-lg border border-dashed border-gray-300 px-4 py-6 text-sm text-gray-600 dark:border-gray-700 dark:text-gray-300">
|
||||
No related context is available for this record.
|
||||
</div>
|
||||
@else
|
||||
@foreach ($entries as $entry)
|
||||
@php
|
||||
$isAvailable = ($entry['availability'] ?? null) === 'available' && filled($entry['targetUrl'] ?? null);
|
||||
@endphp
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-white/80 px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/60">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<div class="text-xs font-medium uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
|
||||
{{ $entry['label'] ?? 'Related record' }}
|
||||
</div>
|
||||
|
||||
@if ($isAvailable)
|
||||
<a href="{{ $entry['targetUrl'] }}" class="text-sm font-semibold text-primary-600 hover:text-primary-500 hover:underline dark:text-primary-400">
|
||||
{{ $entry['value'] ?? 'Open related record' }}
|
||||
</a>
|
||||
@else
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ $entry['value'] ?? 'Unavailable' }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (filled($entry['secondaryValue'] ?? null))
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $entry['secondaryValue'] }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (filled($entry['unavailableReason'] ?? null))
|
||||
<div class="text-xs text-gray-600 dark:text-gray-300">
|
||||
{{ $entry['unavailableReason'] }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
@if ($isAvailable && filled($entry['actionLabel'] ?? null))
|
||||
<a
|
||||
href="{{ $entry['targetUrl'] }}"
|
||||
class="text-xs font-medium text-primary-600 hover:text-primary-500 hover:underline dark:text-primary-400"
|
||||
>
|
||||
{{ $entry['actionLabel'] }}
|
||||
</a>
|
||||
@endif
|
||||
|
||||
@if (filled($entry['contextBadge'] ?? null))
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
{{ $entry['contextBadge'] }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
|
||||
@unless ($isAvailable)
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
Unavailable
|
||||
</x-filament::badge>
|
||||
@endunless
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
@endif
|
||||
</div>
|
||||
@ -0,0 +1,35 @@
|
||||
# Specification Quality Checklist: Cross-Resource Navigation & Drill-Down Cohesion
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-10
|
||||
**Feature**: [spec.md](/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/131-cross-resource-navigation/spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Validation pass 1 completed successfully.
|
||||
- The spec keeps scope on cross-resource drill-down cohesion and canonical navigation, excluding broader IA redesign and new data modeling.
|
||||
@ -0,0 +1,220 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Cross-Resource Navigation Presentation Contract
|
||||
version: 0.1.0
|
||||
description: >-
|
||||
Internal contract for the shared related-navigation presentation layer used by
|
||||
existing Filament pages and resources. This feature introduces no new public HTTP API;
|
||||
the contract formalizes the payloads and rules that drive related-context sections,
|
||||
drill-down actions, canonical route selection, and unavailable-state rendering.
|
||||
paths: {}
|
||||
components:
|
||||
schemas:
|
||||
NavigationMatrixRule:
|
||||
type: object
|
||||
required:
|
||||
- sourceType
|
||||
- sourceSurface
|
||||
- relationKey
|
||||
- targetType
|
||||
- targetMode
|
||||
- label
|
||||
- priority
|
||||
- missingStatePolicy
|
||||
properties:
|
||||
sourceType:
|
||||
type: string
|
||||
example: finding
|
||||
sourceSurface:
|
||||
type: string
|
||||
enum:
|
||||
- detail_section
|
||||
- detail_header
|
||||
- list_row
|
||||
- canonical_list
|
||||
relationKey:
|
||||
type: string
|
||||
example: source_run
|
||||
targetType:
|
||||
type: string
|
||||
example: operation_run
|
||||
targetMode:
|
||||
type: string
|
||||
enum:
|
||||
- direct_record
|
||||
- filtered_list
|
||||
- canonical_page
|
||||
label:
|
||||
type: string
|
||||
example: View run
|
||||
priority:
|
||||
type: integer
|
||||
minimum: 1
|
||||
requiresCapabilityCheck:
|
||||
type: boolean
|
||||
default: true
|
||||
missingStatePolicy:
|
||||
type: string
|
||||
enum:
|
||||
- hide
|
||||
- show_unavailable
|
||||
- show_reference_only
|
||||
RelatedContextSection:
|
||||
type: object
|
||||
required:
|
||||
- title
|
||||
- entries
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
example: Related context
|
||||
entries:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/RelatedContextEntry'
|
||||
primaryEntryKey:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
emptyMessage:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
RelatedContextEntry:
|
||||
type: object
|
||||
required:
|
||||
- key
|
||||
- label
|
||||
- value
|
||||
- targetKind
|
||||
- availability
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
example: baseline_profile
|
||||
label:
|
||||
type: string
|
||||
example: Baseline profile
|
||||
value:
|
||||
type: string
|
||||
example: Windows Security Baseline
|
||||
secondaryValue:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
example: '#42'
|
||||
targetUrl:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
format: uri-reference
|
||||
targetKind:
|
||||
type: string
|
||||
example: baseline_profile
|
||||
availability:
|
||||
type: string
|
||||
enum:
|
||||
- available
|
||||
- missing
|
||||
- unauthorized
|
||||
- unresolved
|
||||
unavailableReason:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
contextBadge:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
example: Tenant context
|
||||
DrillDownAction:
|
||||
type: object
|
||||
required:
|
||||
- label
|
||||
- placement
|
||||
- priority
|
||||
- targetKind
|
||||
- visible
|
||||
properties:
|
||||
label:
|
||||
type: string
|
||||
example: View snapshot
|
||||
url:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
format: uri-reference
|
||||
placement:
|
||||
type: string
|
||||
enum:
|
||||
- row_action
|
||||
- header_action
|
||||
- inline_entry
|
||||
- grouped_action
|
||||
priority:
|
||||
type: integer
|
||||
minimum: 1
|
||||
targetKind:
|
||||
type: string
|
||||
example: baseline_snapshot
|
||||
visible:
|
||||
type: boolean
|
||||
disabledReason:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
CanonicalNavigationContext:
|
||||
type: object
|
||||
required:
|
||||
- workspaceId
|
||||
- sourceSurface
|
||||
- canonicalRouteName
|
||||
properties:
|
||||
workspaceId:
|
||||
type: integer
|
||||
tenantId:
|
||||
type:
|
||||
- integer
|
||||
- 'null'
|
||||
sourceSurface:
|
||||
type: string
|
||||
example: finding.detail
|
||||
canonicalRouteName:
|
||||
type: string
|
||||
example: admin.operations.view
|
||||
filterPayload:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
backLinkLabel:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
example: Back to Findings
|
||||
UnavailableRelationState:
|
||||
type: object
|
||||
required:
|
||||
- relationKey
|
||||
- reason
|
||||
- message
|
||||
- showReference
|
||||
properties:
|
||||
relationKey:
|
||||
type: string
|
||||
example: source_policy_version
|
||||
referenceValue:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
example: 'pv_1282'
|
||||
reason:
|
||||
type: string
|
||||
enum:
|
||||
- missing
|
||||
- deleted
|
||||
- unauthorized
|
||||
- unresolved
|
||||
message:
|
||||
type: string
|
||||
example: Related policy version is no longer available.
|
||||
showReference:
|
||||
type: boolean
|
||||
261
specs/131-cross-resource-navigation/data-model.md
Normal file
261
specs/131-cross-resource-navigation/data-model.md
Normal file
@ -0,0 +1,261 @@
|
||||
# Data Model: Cross-Resource Navigation & Drill-Down Cohesion
|
||||
|
||||
**Feature**: 131-cross-resource-navigation | **Date**: 2026-03-10
|
||||
|
||||
## Overview
|
||||
|
||||
This feature introduces no new database tables. It adds a shared navigation read model and relation-mapping layer over existing workspace-owned and tenant-owned records.
|
||||
|
||||
The design relies on existing persisted entities plus a small set of computed presentation concepts:
|
||||
|
||||
1. a navigation matrix defining allowed operator journeys,
|
||||
2. related-context sections on key detail pages,
|
||||
3. list-level drill-down actions,
|
||||
4. canonical destination context for operations and other authoritative pages,
|
||||
5. explicit unavailable-state handling for missing or unauthorized relations.
|
||||
|
||||
## Existing Persistent Entities
|
||||
|
||||
### Policy
|
||||
|
||||
| Attribute | Type | Notes |
|
||||
|-----------|------|-------|
|
||||
| `id` | int | Primary internal policy identity |
|
||||
| `tenant_id` | int | Tenant ownership boundary |
|
||||
| `workspace_id` | int | Workspace ownership boundary |
|
||||
| `display_name` | string nullable | Primary human-readable label for navigation |
|
||||
| `policy_type` | string | Policy-type hint used in labels and filtered destinations |
|
||||
|
||||
**Relationships**:
|
||||
- has many `PolicyVersion`
|
||||
- may be referenced by findings, backup items, and operation runs through context or foreign keys
|
||||
|
||||
**Usage rules**:
|
||||
- Parent policy is the default upstream destination from a policy version.
|
||||
- Policy links must remain tenant-entitlement checked.
|
||||
|
||||
### PolicyVersion
|
||||
|
||||
| Attribute | Type | Notes |
|
||||
|-----------|------|-------|
|
||||
| `id` | int | Primary version identity |
|
||||
| `policy_id` | int nullable | Parent policy relationship |
|
||||
| `tenant_id` | int | Tenant ownership boundary |
|
||||
| `workspace_id` | int | Workspace ownership boundary |
|
||||
| `version_number` | int or string | Displayed as user-facing version context |
|
||||
| `captured_at` | timestamp | Useful for evidence-related context |
|
||||
|
||||
**Relationships**:
|
||||
- belongs to `Policy`
|
||||
- may be referenced from baseline snapshot items or backup items
|
||||
|
||||
**Usage rules**:
|
||||
- Policy version detail must expose parent policy and related snapshot evidence where resolvable.
|
||||
- When the parent policy cannot be opened, the page must still preserve readable version context.
|
||||
|
||||
### BaselineProfile
|
||||
|
||||
| Attribute | Type | Notes |
|
||||
|-----------|------|-------|
|
||||
| `id` | int | Primary workspace-owned baseline profile identity |
|
||||
| `workspace_id` | int | Workspace ownership boundary |
|
||||
| `name` | string | Primary label |
|
||||
| `status` | string | Badge-backed profile state |
|
||||
| `capture_mode` | string | Useful supporting context on related pages |
|
||||
|
||||
**Relationships**:
|
||||
- has many `BaselineSnapshot`
|
||||
- may be indirectly referenced by findings and operation runs
|
||||
|
||||
**Usage rules**:
|
||||
- Baseline profile is the primary upstream destination from a baseline snapshot.
|
||||
- Baseline profile links are workspace-authorized, not tenant-authorized.
|
||||
|
||||
### BaselineSnapshot
|
||||
|
||||
| Attribute | Type | Notes |
|
||||
|-----------|------|-------|
|
||||
| `id` | int | Primary snapshot identity |
|
||||
| `workspace_id` | int | Workspace ownership boundary |
|
||||
| `baseline_profile_id` | int | Owning baseline profile |
|
||||
| `captured_at` | timestamp | Primary temporal context |
|
||||
| `summary_jsonb` | jsonb | Existing summary and fidelity metadata |
|
||||
|
||||
**Relationships**:
|
||||
- belongs to `BaselineProfile`
|
||||
- may reference policy versions and source operation runs through existing summary or evidence metadata
|
||||
- may be linked to related findings
|
||||
|
||||
**Usage rules**:
|
||||
- Snapshot detail must expose owning profile, source run, and related findings where meaningful and authorized.
|
||||
- Snapshot remains workspace-owned even when it refers to tenant evidence.
|
||||
|
||||
### Finding
|
||||
|
||||
| Attribute | Type | Notes |
|
||||
|-----------|------|-------|
|
||||
| `id` | int | Primary finding identity |
|
||||
| `tenant_id` | int | Tenant ownership boundary |
|
||||
| `workspace_id` | int | Workspace ownership boundary |
|
||||
| `finding_type` | string | Used for operator context and prioritization |
|
||||
| `subject_display_name` | string nullable | Existing human-readable subject label |
|
||||
| `baseline_operation_run_id` | int nullable | Existing related operations link |
|
||||
| `current_operation_run_id` | int nullable | Existing related operations link |
|
||||
| `evidence_jsonb` | jsonb | Existing source-evidence context |
|
||||
|
||||
**Relationships**:
|
||||
- may point to baseline snapshots, policy versions, policies, inventory items, and runs through evidence metadata and explicit IDs
|
||||
|
||||
**Usage rules**:
|
||||
- Findings are high-priority drill-down surfaces and must not degrade into raw IDs when source evidence is resolvable.
|
||||
- Findings remain tenant-authorized even when they link to workspace-owned baseline records.
|
||||
|
||||
### BackupSet
|
||||
|
||||
| Attribute | Type | Notes |
|
||||
|-----------|------|-------|
|
||||
| `id` | int | Primary backup set identity |
|
||||
| `tenant_id` | int | Tenant ownership boundary |
|
||||
| `workspace_id` | int | Workspace ownership boundary |
|
||||
| `name` | string | Primary operator label |
|
||||
| `status` | string | Existing badge-backed lifecycle state |
|
||||
| `item_count` | int | Supporting list context |
|
||||
|
||||
**Relationships**:
|
||||
- has many backup items
|
||||
- may be referenced by operation-run context and restore flows
|
||||
|
||||
**Usage rules**:
|
||||
- Backup set pages should expose related operation runs or resulting artifacts when available.
|
||||
- Backup-related navigation must stay consistent with canonical operations routing.
|
||||
|
||||
### OperationRun
|
||||
|
||||
| Attribute | Type | Notes |
|
||||
|-----------|------|-------|
|
||||
| `id` | int | Canonical run identity |
|
||||
| `workspace_id` | int | Workspace ownership boundary |
|
||||
| `tenant_id` | int nullable | Nullable for some workspace-level operations |
|
||||
| `type` | string | Operation type driving related destinations |
|
||||
| `status` | string | Existing run status badge |
|
||||
| `outcome` | string nullable | Existing outcome badge |
|
||||
| `context` | json/jsonb | Existing target-resource and workflow metadata |
|
||||
|
||||
**Relationships**:
|
||||
- may point to policies, backup sets, restore runs, baseline compare destinations, and other domain records via context
|
||||
|
||||
**Usage rules**:
|
||||
- `OperationRun` is the canonical operational drill-down target.
|
||||
- Related domain links should be generated from run context only when target authorization can be proven.
|
||||
|
||||
## New Computed Read Models
|
||||
|
||||
### NavigationMatrixRule
|
||||
|
||||
| Field | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `source_type` | string | Resource or page type that owns the navigation surface |
|
||||
| `source_surface` | string enum | `detail_section`, `detail_header`, `list_row`, `canonical_list` |
|
||||
| `relation_key` | string | Stable semantic relation identifier, e.g. `source_run`, `parent_policy` |
|
||||
| `target_type` | string | Destination resource or page type |
|
||||
| `target_mode` | string enum | `direct_record`, `filtered_list`, `canonical_page` |
|
||||
| `label` | string | Shared operator-facing action or entry label |
|
||||
| `priority` | int | Lower number = higher visibility priority |
|
||||
| `requires_capability_check` | bool | Whether actionability depends on explicit authorization |
|
||||
| `missing_state_policy` | string enum | `hide`, `show_unavailable`, `show_reference_only` |
|
||||
|
||||
**Rules**:
|
||||
- Every in-scope relationship in the spec’s navigation matrix maps to one rule.
|
||||
- Rules choose only one canonical destination for the same operator task.
|
||||
|
||||
### RelatedContextSection
|
||||
|
||||
| Field | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `title` | string | Usually `Related context` or a narrow equivalent |
|
||||
| `entries` | list<RelatedContextEntry> | Ordered related records for the current page |
|
||||
| `primary_entry_key` | string nullable | Optional marker for the most likely next step |
|
||||
| `empty_message` | string nullable | Optional empty-state copy when no related entries are available |
|
||||
|
||||
**Rules**:
|
||||
- Detail pages should render one structured section rather than scattering relation fields.
|
||||
- Entries are ordered by operator relevance, not by storage order.
|
||||
|
||||
### RelatedContextEntry
|
||||
|
||||
| Field | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `key` | string | Stable identifier such as `source_run` or `baseline_profile` |
|
||||
| `label` | string | User-facing relation label |
|
||||
| `value` | string | Human-readable related object label |
|
||||
| `secondary_value` | string nullable | Optional technical ID or context hint |
|
||||
| `target_url` | string nullable | Drill-down destination when actionable |
|
||||
| `target_kind` | string | Resource or canonical page type |
|
||||
| `availability` | string enum | `available`, `missing`, `unauthorized`, `unresolved` |
|
||||
| `unavailable_reason` | string nullable | User-safe explanation when not actionable |
|
||||
| `context_badge` | string nullable | Optional workspace, tenant, or type hint |
|
||||
|
||||
**Rules**:
|
||||
- `value` should favor human-readable labels over raw IDs.
|
||||
- Technical identifiers may appear only as secondary supporting context.
|
||||
|
||||
### DrillDownAction
|
||||
|
||||
| Field | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `label` | string | Shared operator-facing action label |
|
||||
| `url` | string | Target destination |
|
||||
| `placement` | string enum | `row_action`, `header_action`, `inline_entry`, `grouped_action` |
|
||||
| `priority` | int | Governs whether it appears inline or only in grouped context |
|
||||
| `target_kind` | string | Destination type |
|
||||
| `visible` | bool | Whether action should be rendered at all |
|
||||
| `disabled_reason` | string nullable | Optional tooltip or unavailable-state explanation |
|
||||
|
||||
**Rules**:
|
||||
- No more than the highest-priority actions should remain visibly inline on list rows.
|
||||
- Actions to canonical operations pages must resolve through the canonical helper layer.
|
||||
|
||||
### CanonicalNavigationContext
|
||||
|
||||
| Field | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `workspace_id` | int | Active workspace scope |
|
||||
| `tenant_id` | int nullable | Originating tenant context if preserved |
|
||||
| `source_surface` | string | Resource or page that launched the navigation |
|
||||
| `canonical_route_name` | string | Authoritative destination route name |
|
||||
| `filter_payload` | array | Context-preserving query or filter state |
|
||||
| `back_link_label` | string nullable | Explicit return path label |
|
||||
|
||||
**Rules**:
|
||||
- Canonical route identity must remain stable even when tenant context is preserved.
|
||||
- This model is especially important for operations and monitoring surfaces.
|
||||
|
||||
### UnavailableRelationState
|
||||
|
||||
| Field | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `relation_key` | string | Relation that could not be resolved |
|
||||
| `reference_value` | string nullable | Technical identifier or fallback reference |
|
||||
| `reason` | string enum | `missing`, `deleted`, `unauthorized`, `unresolved` |
|
||||
| `message` | string | User-facing explanation |
|
||||
| `show_reference` | bool | Whether the secondary reference is safe to display |
|
||||
|
||||
**Rules**:
|
||||
- Unauthorized states must not disclose more than existing access rules allow.
|
||||
- Missing and unresolved states must preserve surrounding page usability and layout.
|
||||
|
||||
## Validation Rules
|
||||
|
||||
| Rule | Result |
|
||||
|------|--------|
|
||||
| Every in-scope relationship resolves through one explicit navigation-matrix rule | Required |
|
||||
| Canonical run destinations always use the tenantless operations route family | Required |
|
||||
| Non-members receive 404 semantics for target resources | Required |
|
||||
| In-scope members lacking capability may see disabled or unavailable context, but target execution still fails with 403 | Required |
|
||||
| Related-context entries show human-readable labels first and technical IDs second | Required |
|
||||
| List-level drill-down actions remain limited to the highest-priority operator journeys | Required |
|
||||
| Missing or unresolved relations render clear unavailable states without broken links | Required |
|
||||
|
||||
## Schema Impact
|
||||
|
||||
No schema migration is expected for this feature.
|
||||
247
specs/131-cross-resource-navigation/plan.md
Normal file
247
specs/131-cross-resource-navigation/plan.md
Normal file
@ -0,0 +1,247 @@
|
||||
# Implementation Plan: Cross-Resource Navigation & Drill-Down Cohesion
|
||||
|
||||
**Branch**: `131-cross-resource-navigation` | **Date**: 2026-03-10 | **Spec**: [spec.md](./spec.md)
|
||||
**Input**: Feature specification from `/specs/131-cross-resource-navigation/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Unify cross-resource navigation across governance, inventory, backup, and monitoring surfaces by introducing a shared relation-navigation presentation layer, standardizing canonical operations links through the existing tenantless operations route family, and adding predictable related-context sections plus limited drill-down actions on the highest-value resource pages. The implementation stays read-only, reuses existing Filament resources and policies, preserves workspace and tenant isolation, and concentrates first on findings, baseline snapshots, policy versions, backup sets, and canonical operations.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15 / Laravel 12
|
||||
**Primary Dependencies**: Filament v5, Livewire v4.0+, Tailwind CSS v4
|
||||
**Storage**: PostgreSQL via Laravel Sail, plus existing session-backed workspace and tenant context
|
||||
**Testing**: Pest v4 feature and unit tests on PHPUnit 12
|
||||
**Target Platform**: Laravel Sail web application with workspace-admin routes under `/admin`, tenant-context routes under `/admin/t/{tenant}/...`, and canonical workspace-level operations routes under `/admin/operations...`
|
||||
**Project Type**: Laravel monolith / Filament web application
|
||||
**Performance Goals**: Related-context rendering remains DB-only at page render time, uses bounded eager-loaded relations, adds no new uncontrolled polling, and keeps canonical operations routing free of duplicate route families
|
||||
**Constraints**: No schema changes, no new Graph calls, no new operational side effects, no raw capability strings, no ad hoc URLs sprinkled across resources, preserve 404 vs 403 semantics, and keep link noise capped to the most relevant operator journeys
|
||||
**Scale/Scope**: One shared navigation abstraction, one explicit navigation matrix, five primary resource families plus canonical operations surfaces, and focused regression coverage for navigation visibility, authorization, canonical routing, and unavailable states
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- Inventory-first: PASS — the feature changes navigation and presentation only; inventory, snapshots, and backups remain distinct persisted concepts.
|
||||
- Read/write separation: PASS — no new write flow is introduced; the feature adds read-only navigation and context affordances only.
|
||||
- Graph contract path: PASS — no Microsoft Graph calls are added.
|
||||
- Deterministic capabilities: PASS — link visibility and availability can be derived from existing capability resolvers and policies.
|
||||
- RBAC-UX planes and isolation: PASS — the feature spans workspace-admin `/admin` and tenant-context `/admin/t/{tenant}/...` entry points but preserves canonical workspace-level operations destinations, 404 for non-members, and 403 for in-scope capability denial.
|
||||
- Workspace isolation: PASS — workspace membership remains the visibility boundary for workspace-admin and canonical operations surfaces.
|
||||
- RBAC-UX destructive confirmation: PASS / N/A — no new destructive actions are introduced.
|
||||
- RBAC-UX global search: PASS — no new global-searchable resource is introduced; touched resources either already have view pages or are explicitly not globally searchable.
|
||||
- Tenant isolation: PASS — tenant-owned resource links must be tenant-entitlement checked before rendering actionable destinations.
|
||||
- Run observability: PASS / N/A — existing `OperationRun` records are reused as destinations only; no new runs or lifecycle transitions are introduced.
|
||||
- Ops-UX 3-surface feedback: PASS / N/A — no new operation start surface or completion behavior is added.
|
||||
- Ops-UX lifecycle and summary counts: PASS / N/A — no `OperationRun` status or outcome mutation occurs in this feature.
|
||||
- Ops-UX guards and system runs: PASS / N/A — existing operations behavior remains unchanged.
|
||||
- Automation: PASS / N/A — no queued or scheduled workflow changes are required.
|
||||
- Data minimization: PASS — related context and drill-down metadata remain DB-derived and should expose only already-authorized operator-facing labels.
|
||||
- Badge semantics (BADGE-001): PASS — any context or availability badges introduced by the related-context pattern must use existing centralized badge semantics.
|
||||
- UI naming (UI-NAMING-001): PASS — the spec standardizes operator-facing labels around `View ...` and `Open ...` vocabulary and avoids implementation-first terms.
|
||||
- Filament UI Action Surface Contract: PASS — touched resources already have view/list surfaces; the feature will upgrade inspect affordances and related actions without introducing action sprawl or lone non-canonical view buttons.
|
||||
- Filament UI UX-001: PASS — related context will be added through sectioned infolist or view-page layouts rather than ad hoc text fields.
|
||||
- Filament v5 / Livewire v4 compliance: PASS — the plan remains inside the existing Filament v5 / Livewire v4 admin and tenant panels.
|
||||
- Provider registration (`bootstrap/providers.php`): PASS — no new panel provider is added; existing Filament providers remain registered in `bootstrap/providers.php`.
|
||||
- Global search resource rule: PASS — `BaselineSnapshotResource`, `BaselineProfileResource`, and `OperationRunResource` explicitly disable global search; `PolicyResource`, `PolicyVersionResource`, `FindingResource`, and `BackupSetResource` already expose view pages, so no new global-search dead end is introduced.
|
||||
- Asset strategy: PASS — no new heavy asset bundle is required; current deployment behavior, including `php artisan filament:assets`, remains sufficient.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/131-cross-resource-navigation/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ └── cross-resource-navigation.openapi.yaml
|
||||
├── checklists/
|
||||
│ └── requirements.md
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
app/
|
||||
├── Filament/
|
||||
│ ├── Pages/
|
||||
│ │ ├── BaselineCompareLanding.php # baseline comparison landing with canonical run links
|
||||
│ │ ├── Monitoring/
|
||||
│ │ │ └── Operations.php # canonical operations index
|
||||
│ │ └── Operations/
|
||||
│ │ └── TenantlessOperationRunViewer.php # canonical run detail
|
||||
│ ├── Resources/
|
||||
│ │ ├── PolicyResource.php # tenant policy list/view + versions relation
|
||||
│ │ ├── PolicyVersionResource.php # tenant policy version list/view
|
||||
│ │ ├── BaselineProfileResource.php # workspace baseline profile list/view
|
||||
│ │ ├── BaselineSnapshotResource.php # workspace baseline snapshot list/view
|
||||
│ │ ├── FindingResource.php # tenant findings list/view
|
||||
│ │ ├── BackupSetResource.php # tenant backup set list/view
|
||||
│ │ ├── OperationRunResource.php # shared operations table/infolist config
|
||||
│ │ └── BackupSetResource/
|
||||
│ │ └── RelationManagers/
|
||||
│ │ └── BackupItemsRelationManager.php # existing policy/version cross-links
|
||||
│ └── Widgets/
|
||||
│ └── Dashboard/ # existing recent findings/operations patterns
|
||||
├── Policies/
|
||||
│ ├── OperationRunPolicy.php # canonical run authorization semantics
|
||||
│ └── FindingPolicy.php # tenant finding authorization semantics
|
||||
├── Support/
|
||||
│ ├── OperationRunLinks.php # current canonical operations URL helper
|
||||
│ ├── OperateHub/ # tenant-context preservation patterns
|
||||
│ ├── Rbac/ # UI enforcement helpers
|
||||
│ └── Ui/
|
||||
│ └── ActionSurface/ # action-surface contract helpers
|
||||
routes/
|
||||
└── web.php # canonical operations route definitions
|
||||
resources/
|
||||
└── views/
|
||||
└── filament/ # existing infolist/page partials for read-only detail rendering
|
||||
tests/
|
||||
├── Feature/
|
||||
│ ├── Filament/ # resource page and view-surface tests
|
||||
│ ├── Findings/ # finding list/view behavior tests
|
||||
│ ├── Monitoring/ # canonical operations route tests
|
||||
│ ├── Operations/ # tenantless run viewer tests
|
||||
│ └── Rbac/ # capability-aware UI enforcement tests
|
||||
└── Unit/
|
||||
├── Baselines/ # existing snapshot/policy resolver tests
|
||||
└── Support/ # new navigation resolver / link builder tests
|
||||
```
|
||||
|
||||
**Structure Decision**: Keep the feature inside the existing Laravel/Filament monolith. Implement a shared support-layer navigation abstraction beside `OperationRunLinks` and the existing UI support helpers, then wire it into the current resource list and view surfaces rather than introducing new panels, routes, or data stores.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
> No Constitution Check violations. No justifications needed.
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| — | — | — |
|
||||
|
||||
## Phase 0 — Research (DONE)
|
||||
|
||||
Output:
|
||||
- `specs/131-cross-resource-navigation/research.md`
|
||||
|
||||
Key findings captured:
|
||||
- Canonical operations routing already exists through `route('admin.operations.view')`, `TenantlessOperationRunViewer`, and `OperationRunLinks`, but related deep links are still scattered across individual resources and widgets.
|
||||
- Existing resources already expose a mix of infolist sections, row actions, relation managers, and `recordUrl()` patterns; the gap is consistency, not missing primitives.
|
||||
- Existing policies already model the required 404 vs 403 semantics, so link generation must be capability-aware rather than optimistic.
|
||||
- The action-surface contract and Filament guidance both favor explicit inspect affordances and limited visible row actions, which aligns with the spec’s dead-end reduction and anti-link-sprawl goals.
|
||||
- The highest-value initial resource set already has focused test neighborhoods, making incremental rollout practical.
|
||||
|
||||
## Phase 1 — Design & Contracts (DONE)
|
||||
|
||||
Outputs:
|
||||
- `specs/131-cross-resource-navigation/data-model.md`
|
||||
- `specs/131-cross-resource-navigation/contracts/cross-resource-navigation.openapi.yaml`
|
||||
- `specs/131-cross-resource-navigation/quickstart.md`
|
||||
|
||||
Design highlights:
|
||||
- Introduce a reusable related-navigation presentation model instead of continuing to build URLs and labels inline inside each resource.
|
||||
- Keep `OperationRunLinks` as the canonical operations route helper, but expand the surrounding design so non-operation resources feed into it consistently.
|
||||
- Add structured related-context sections to key view pages using existing infolist section patterns and explicit shared label vocabulary.
|
||||
- Standardize list-level drill-down actions around a strict priority model: upstream evidence first, canonical run or operations context second, and filtered-list follow-up third, while keeping a primary inspect affordance plus at most two visible related actions.
|
||||
- Preserve tenant context on canonical operations pages through explicit context hints or filter state, following the existing operate-hub shell patterns.
|
||||
- Make breadcrumb and back-link lineage explicit on canonical operations surfaces so return navigation follows resource lineage or filtered-list origin rather than browser history guesswork.
|
||||
|
||||
## Phase 1 — Agent Context Update (DONE)
|
||||
|
||||
Run:
|
||||
- `.specify/scripts/bash/update-agent-context.sh copilot`
|
||||
|
||||
## Phase 2 — Implementation Outline (tasks created in `/speckit.tasks`)
|
||||
|
||||
### Step 1 — Define the shared navigation matrix and support layer
|
||||
|
||||
Goal: implement FR-131-15, FR-131-22, FR-131-23, and FR-131-24.
|
||||
|
||||
Changes:
|
||||
- Add a centralized relation-mapping and presentation layer for related context entries, row actions, and canonical drill-down targets.
|
||||
- Define the explicit matrix for Policy, PolicyVersion, BaselineSnapshot, BaselineProfile, Finding, BackupSet, and OperationRun relationships.
|
||||
- Centralize shared operator-facing labels and unavailable-state reasons so resources do not diverge.
|
||||
|
||||
Tests:
|
||||
- Add unit coverage for the relation resolver, label generation, availability states, and canonical-target selection.
|
||||
|
||||
### Step 2 — Standardize canonical operations links
|
||||
|
||||
Goal: implement FR-131-03, FR-131-04, FR-131-14, FR-131-20, and FR-131-21.
|
||||
|
||||
Changes:
|
||||
- Refactor all in-scope “View run” and operations-related links to flow through `OperationRunLinks` and canonical operations routes.
|
||||
- Align contextual back links, breadcrumb lineage, and tenant-context preservation with the existing `OperateHubShell` pattern.
|
||||
- Remove or replace ad hoc direct route generation where equivalent canonical helper behavior exists.
|
||||
|
||||
Tests:
|
||||
- Extend existing monitoring and operations tests to assert canonical route usage and tenant-context preservation.
|
||||
- Add regression coverage proving tenant-specific non-canonical run links do not reappear on in-scope surfaces.
|
||||
|
||||
### Step 3 — Add structured related-context sections to key detail pages
|
||||
|
||||
Goal: implement FR-131-01, FR-131-02, FR-131-05, FR-131-06, FR-131-10, FR-131-16, FR-131-17, FR-131-18, and FR-131-19.
|
||||
|
||||
Changes:
|
||||
- Add a structured related-context section to Finding, BaselineSnapshot, PolicyVersion, BackupSet, and canonical OperationRun detail surfaces.
|
||||
- Use human-readable labels first, technical identifiers second, and explicit unavailable states where relations cannot be opened.
|
||||
- Prefer infolist sections or view-page header actions over custom one-off Blade logic where existing resource patterns suffice.
|
||||
- Prove future extensibility by keeping new relation types matrix-driven through the shared label catalog and related-context renderer rather than resource-specific branching.
|
||||
|
||||
Tests:
|
||||
- Add feature coverage proving the expected related context renders on key detail pages when relations exist.
|
||||
- Add negative-path coverage for missing or unauthorized related targets.
|
||||
|
||||
### Step 4 — Add standard list-level drill-down actions
|
||||
|
||||
Goal: implement FR-131-07, FR-131-08, FR-131-09, and FR-131-10.
|
||||
|
||||
Changes:
|
||||
- Add or normalize row-level related actions on findings, policy versions, backup sets, and relevant operations lists.
|
||||
- Preserve each table’s inspect affordance while capping visible row actions and pushing overflow into grouped actions only when genuinely needed.
|
||||
- Replace raw ID or count-only relation renderings with accessible drill-down labels where operationally important.
|
||||
|
||||
Tests:
|
||||
- Add Filament list tests asserting related row actions appear only when relevant and stay hidden or disabled when access is not allowed.
|
||||
- Add regression coverage for action vocabulary consistency on in-scope list surfaces.
|
||||
|
||||
### Step 5 — Wire graceful unavailable states and authorization-aware rendering
|
||||
|
||||
Goal: implement FR-131-11, FR-131-12, and FR-131-13.
|
||||
|
||||
Changes:
|
||||
- Ensure relation resolvers can distinguish missing, unresolved, and unauthorized states.
|
||||
- Render non-clickable unavailable states only when policy allows awareness of the relation.
|
||||
- Preserve server-side target authorization through existing policies and UI enforcement helpers.
|
||||
|
||||
Tests:
|
||||
- Add positive and negative authorization coverage proving non-members get 404 semantics, members without capability get 403 at the destination, and unavailable states do not leak protected detail.
|
||||
|
||||
### Step 6 — Finalize rollout, docs-in-code consistency, and regression safety
|
||||
|
||||
Goal: implement the remaining acceptance criteria and protect the pattern from drift.
|
||||
|
||||
Changes:
|
||||
- Audit in-scope resources for leftover raw IDs, inconsistent labels, and direct route generation.
|
||||
- Ensure canonical operations links, related-context sections, and row actions follow the explicit matrix rather than page-by-page exceptions.
|
||||
- Keep rollout incremental, starting with findings, snapshots, policy versions, and operations before extending to backup sets and the remaining aligned surfaces.
|
||||
|
||||
Tests:
|
||||
- Run focused Pest suites for findings, monitoring, operations, baselines, policies, and backup UI surfaces.
|
||||
- Run Sail-based Pint on dirty files during implementation.
|
||||
|
||||
## Constitution Check (Post-Design)
|
||||
|
||||
Re-check result: PASS.
|
||||
|
||||
- Livewire v4.0+ compliance: preserved because the design remains inside the existing Filament v5 / Livewire v4 application surfaces.
|
||||
- Provider registration location: unchanged; existing panel providers remain registered in `bootstrap/providers.php`.
|
||||
- Globally searchable resources: `BaselineSnapshotResource`, `BaselineProfileResource`, and `OperationRunResource` keep global search disabled; `PolicyResource`, `PolicyVersionResource`, `FindingResource`, and `BackupSetResource` already have view pages, so no new global-search dead end is created.
|
||||
- Destructive actions: no new destructive actions are added by this feature; existing destructive actions remain subject to `->requiresConfirmation()` and current authorization rules.
|
||||
- Asset strategy: no new heavy assets are introduced; existing Filament assets remain sufficient, and deploy-time `php artisan filament:assets` behavior is unchanged.
|
||||
- Testing plan: add focused Pest feature coverage for related-context rendering, canonical operations links, list-level drill-down actions, unavailable states, and authorization-aware visibility, plus unit tests for the shared relation-mapping and presentation layer.
|
||||
89
specs/131-cross-resource-navigation/quickstart.md
Normal file
89
specs/131-cross-resource-navigation/quickstart.md
Normal file
@ -0,0 +1,89 @@
|
||||
# Quickstart: Cross-Resource Navigation & Drill-Down Cohesion
|
||||
|
||||
**Feature**: 131-cross-resource-navigation | **Date**: 2026-03-10
|
||||
|
||||
## Scope
|
||||
|
||||
This feature standardizes operator navigation across related governance, monitoring, inventory, and backup resources by:
|
||||
|
||||
- defining one explicit navigation matrix for the highest-value resource relationships,
|
||||
- routing all run-related drill-downs through canonical operations destinations,
|
||||
- adding structured related-context sections to key detail pages,
|
||||
- adding limited, high-value row-level drill-down actions on list pages,
|
||||
- rendering missing or unauthorized relations as clear unavailable states.
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. Create the shared relation-navigation support layer and encode the initial navigation matrix.
|
||||
2. Refactor in-scope run-related links to flow through the canonical `OperationRunLinks` helper consistently.
|
||||
3. Add structured related-context sections to `Finding`, `BaselineSnapshot`, `PolicyVersion`, `BackupSet`, and canonical operation-run detail pages.
|
||||
4. Add or normalize list-level related drill-down actions on findings, policy versions, backup sets, and operations-related surfaces.
|
||||
5. Align explicit back-link and context-preservation behavior on canonical operations pages with the existing operate-hub shell patterns.
|
||||
6. Add graceful unavailable-state rendering for missing, unresolved, and unauthorized relations.
|
||||
7. Add focused Pest unit and feature coverage for the shared navigation layer, canonical routing, UI visibility, and authorization-aware degradation.
|
||||
8. Run focused Sail-based tests.
|
||||
9. Run Pint on dirty files.
|
||||
|
||||
## Reference files
|
||||
|
||||
- `app/Support/OperationRunLinks.php`
|
||||
- `routes/web.php`
|
||||
- `app/Filament/Pages/Monitoring/Operations.php`
|
||||
- `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`
|
||||
- `app/Filament/Resources/OperationRunResource.php`
|
||||
- `app/Filament/Resources/FindingResource.php`
|
||||
- `app/Filament/Resources/BaselineSnapshotResource.php`
|
||||
- `app/Filament/Resources/BaselineProfileResource.php`
|
||||
- `app/Filament/Resources/PolicyResource.php`
|
||||
- `app/Filament/Resources/PolicyVersionResource.php`
|
||||
- `app/Filament/Resources/BackupSetResource.php`
|
||||
- `app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php`
|
||||
- `app/Policies/OperationRunPolicy.php`
|
||||
- `app/Policies/FindingPolicy.php`
|
||||
- `tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php`
|
||||
- `tests/Feature/Operations/TenantlessOperationRunViewerTest.php`
|
||||
- `tests/Feature/Findings/FindingWorkflowViewActionsTest.php`
|
||||
- `tests/Feature/Findings/FindingWorkflowRowActionsTest.php`
|
||||
- `tests/Feature/Filament/BaselineSnapshotStructuredRenderingTest.php`
|
||||
- `tests/Feature/Filament/PolicyVersionBaselineEvidenceVisibilityTest.php`
|
||||
- `tests/Feature/Filament/BackupSetUiEnforcementTest.php`
|
||||
|
||||
## Suggested new tests
|
||||
|
||||
- `tests/Feature/Findings/FindingRelatedNavigationTest.php`
|
||||
- `tests/Feature/Filament/BaselineSnapshotRelatedContextTest.php`
|
||||
- `tests/Feature/Filament/PolicyVersionRelatedNavigationTest.php`
|
||||
- `tests/Feature/Filament/BackupSetRelatedNavigationTest.php`
|
||||
- `tests/Feature/Monitoring/OperationsRelatedNavigationTest.php`
|
||||
- `tests/Feature/Rbac/CrossResourceNavigationAuthorizationTest.php`
|
||||
- `tests/Unit/Support/RelatedContextResolverTest.php`
|
||||
- `tests/Unit/Support/CanonicalNavigationContextTest.php`
|
||||
|
||||
## Suggested validation commands
|
||||
|
||||
```bash
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Operations/TenantlessOperationRunViewerTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingWorkflowViewActionsTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingWorkflowRowActionsTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineSnapshotStructuredRenderingTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/PolicyVersionBaselineEvidenceVisibilityTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/BackupSetUiEnforcementTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingRelatedNavigationTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineSnapshotRelatedContextTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/PolicyVersionRelatedNavigationTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/BackupSetRelatedNavigationTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Monitoring/OperationsRelatedNavigationTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Rbac/CrossResourceNavigationAuthorizationTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Unit/Support/RelatedContextResolverTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Unit/Support/CanonicalNavigationContextTest.php
|
||||
vendor/bin/sail bin pint --dirty --format agent
|
||||
```
|
||||
|
||||
## Expected outcome
|
||||
|
||||
- Findings, snapshots, policy versions, backup sets, and operation runs stop behaving like isolated records.
|
||||
- Canonical operations routes become the single authoritative destination for run-oriented drill-downs.
|
||||
- Related records appear through structured context sections and limited high-value actions instead of raw IDs and ad hoc links.
|
||||
- Missing or inaccessible relations degrade clearly without broken links or route leakage.
|
||||
- The shared navigation pattern is reusable for future artifacts without rewriting every resource page manually.
|
||||
83
specs/131-cross-resource-navigation/research.md
Normal file
83
specs/131-cross-resource-navigation/research.md
Normal file
@ -0,0 +1,83 @@
|
||||
# Research: Cross-Resource Navigation & Drill-Down Cohesion
|
||||
|
||||
**Feature**: 131-cross-resource-navigation | **Date**: 2026-03-10
|
||||
|
||||
## R1: Canonical operations links should continue through the existing tenantless route family
|
||||
|
||||
**Decision**: Reuse `OperationRunLinks` and the tenantless operations route family as the authoritative destination for all in-scope run-related drill-downs.
|
||||
|
||||
**Rationale**: The codebase already converges canonical run detail through `route('admin.operations.view')`, `OperationRunLinks::tenantlessView()`, and `TenantlessOperationRunViewer`. `OperationRunLinks::view()` already normalizes tenant-context callers onto the tenantless route, and the operations page preserves context through `OperateHubShell` instead of separate route families. Extending this pattern is lower-risk than creating new destination helpers or permitting direct resource-specific run routes to spread further.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Let each resource continue generating run URLs inline: rejected because route drift is already present and the spec explicitly targets canonical-route consistency.
|
||||
- Reintroduce tenant-bound run detail URLs for convenience: rejected because spec 078 already moved operations toward workspace-level canonical routes.
|
||||
|
||||
## R2: Relation mapping must move out of individual resources and into a shared support layer
|
||||
|
||||
**Decision**: Introduce a shared navigation presentation layer that resolves related-context entries, drill-down actions, labels, and unavailable states outside individual Filament resources.
|
||||
|
||||
**Rationale**: Current relation behavior is fragmented across `FindingResource`, `BackupItemsRelationManager`, `PolicyResource` relation managers, widgets, and `OperationRunLinks`. That fragmentation is precisely what the feature is trying to end. The product already uses support-layer abstractions for RBAC enforcement, action-surface rules, and badge semantics; a similar support-layer abstraction is the most consistent place for cross-resource relation logic.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Keep relation logic inside each resource and standardize only labels: rejected because labels alone do not solve availability, canonical target, or missing-state drift.
|
||||
- Put all relation mapping inside Blade partials: rejected because authorization, route selection, and testability belong above the view layer.
|
||||
|
||||
## R3: Detail-page related context should be implemented as structured infolist sections plus header actions
|
||||
|
||||
**Decision**: Add related context to key detail pages through infolist sections and predictable header or inline actions, not bespoke one-off widgets.
|
||||
|
||||
**Rationale**: The in-scope resources already use sectioned infolist-based detail pages, and Filament v5 documents header actions and view-page infolist testing as the intended extension points. This matches the constitution’s UX-001 requirement and keeps detail pages consistent with existing read-only resource patterns.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Use custom Blade page headers or embedded dashboards for every resource: rejected because it increases implementation variance and makes action testing harder.
|
||||
- Put all related links only in header actions: rejected because the spec requires a structured related-context section, not just header buttons.
|
||||
|
||||
## R4: List-level navigation should prioritize inspect affordance and a small visible action set
|
||||
|
||||
**Decision**: Use existing inspect affordances plus a small number of visible row-level related actions, with no more than the most relevant journeys exposed inline.
|
||||
|
||||
**Rationale**: Filament v5 supports clickable rows via `recordUrl()`, and the repo’s action-surface contract already requires an inspection affordance and discourages noisy row-action layouts. The spec’s goal is coherent workflows, not maximal link density. That means keeping the primary record-open action obvious, then surfacing one or two high-value related actions where they materially reduce dead ends.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Expose every related destination on each row: rejected because it creates link sprawl and undermines scannability.
|
||||
- Move all related navigation to detail pages only: rejected because the spec explicitly calls for standard drill-down actions on important list screens.
|
||||
|
||||
## R5: Authorization-aware link rendering should rely on target policies and entitlement checks, not optimistic links
|
||||
|
||||
**Decision**: Generate actionable related links only when the current user is entitled to the target resource; otherwise render a clear unavailable state only when awareness of the relation is allowed.
|
||||
|
||||
**Rationale**: `OperationRunPolicy` already enforces the required 404 vs 403 semantics, and `FindingPolicy` plus tenant membership checks show that tenant-entitlement is a hard visibility boundary. The UI cannot safely assume a visible relation implies an accessible destination. The new navigation layer therefore has to treat authorization as part of relation resolution, not as a last-second target-page failure.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Render the link and rely on the destination page to fail: rejected because it creates trust-damaging UX and can leak too much about inaccessible records.
|
||||
- Hide all inaccessible relations entirely: rejected because the spec allows graceful non-clickable unavailable states where policy permits relation awareness.
|
||||
|
||||
## R6: Breadcrumb and back behavior should follow explicit contextual navigation, not browser-history heuristics
|
||||
|
||||
**Decision**: Use explicit contextual back links and canonical lineage semantics, following the existing operations pages’ `Back to ...` and `Show all ...` patterns.
|
||||
|
||||
**Rationale**: `TenantlessOperationRunViewer` and `Monitoring\Operations` already implement explicit context-aware back actions rather than trusting browser history. That existing pattern is the right precedent for Spec 131 because the problem statement calls out mental-model failures caused by history-dependent back behavior.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Depend on browser history or referrer to preserve origin: rejected because it is fragile and opaque.
|
||||
- Force every destination to rebuild the exact originating filtered list state: rejected because it over-scopes the feature into full navigation-state persistence.
|
||||
|
||||
## R7: The first implementation slice should target the resources with the strongest existing evidence and test coverage
|
||||
|
||||
**Decision**: Prioritize Finding, BaselineSnapshot, PolicyVersion, BackupSet, and canonical operations surfaces first, while treating Policy and BaselineProfile as supporting parents in the same matrix.
|
||||
|
||||
**Rationale**: These resources already have dense existing test neighborhoods, clear operator workflows, and visible pain points around run links, source evidence, and related record traversal. Starting here yields the highest workflow value while staying incremental.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Attempt to align every possible related resource in one pass: rejected because the spec explicitly permits incremental rollout and warns against IA over-scope.
|
||||
- Start from lower-value supporting resources first: rejected because it delays the highest operator-impact journeys.
|
||||
|
||||
## R8: Contract artifact should model the shared navigation payload, not invent new APIs
|
||||
|
||||
**Decision**: Publish an internal OpenAPI artifact that formalizes the shared related-navigation payload and rule schemas without defining new HTTP endpoints.
|
||||
|
||||
**Rationale**: This feature does not add backend APIs; it standardizes UI-side navigation and related-context generation across existing Filament pages. A components-focused OpenAPI document still provides a formal contract for the presentation layer without pretending the feature introduces new REST resources.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Omit a contract artifact entirely: rejected because the planning workflow requires contracts output.
|
||||
- Invent new HTTP endpoints purely to satisfy the planning template: rejected because that would distort the feature scope and mislead future implementation.
|
||||
191
specs/131-cross-resource-navigation/spec.md
Normal file
191
specs/131-cross-resource-navigation/spec.md
Normal file
@ -0,0 +1,191 @@
|
||||
# Feature Specification: Cross-Resource Navigation & Drill-Down Cohesion
|
||||
|
||||
**Feature Branch**: `131-cross-resource-navigation`
|
||||
**Created**: 2026-03-10
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Establish consistent cross-resource navigation and drill-down flows across related enterprise resources"
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: canonical-view
|
||||
- **Primary Routes**:
|
||||
- Workspace admin governance and inventory detail pages under `/admin/...`
|
||||
- Canonical workspace operations and monitoring surfaces under `/admin/operations...`
|
||||
- Tenant-context entry points under `/admin/t/{tenant}/...` only where they launch canonical related views with preserved context
|
||||
- **Data Ownership**:
|
||||
- Existing workspace-owned governance, monitoring, backup, and inventory records remain the source of truth
|
||||
- Existing tenant-scoped records remain unchanged; this feature standardizes how their relationships are presented and traversed
|
||||
- No new persisted domain model is introduced; only navigation, presentation, and relation mapping behavior change
|
||||
- **RBAC**:
|
||||
- Workspace membership remains the primary boundary for visibility across admin resources
|
||||
- Target-resource capability checks remain required before rendering actionable navigation to related records
|
||||
- Cross-resource navigation must preserve deny-as-not-found behavior for non-members and under-entitled tenant scope
|
||||
|
||||
For canonical-view specs, the spec MUST define:
|
||||
|
||||
- **Default filter behavior when tenant-context is active**: When a user launches a canonical operations or monitoring destination from a tenant-context screen, the destination opens on the authoritative workspace-level route with the originating tenant applied as visible filter or context badge when relevant.
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: Canonical destinations must enforce existing workspace membership and tenant-scope entitlements before rendering related records; users outside the permitted workspace or tenant scope must not receive target details, related labels, or actionable deep links.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Trace a finding to its source (Priority: P1)
|
||||
|
||||
As a governance operator, I want to move from a finding to its related snapshot, policy context, and source run without copying identifiers so I can investigate drift and evidence quickly.
|
||||
|
||||
**Why this priority**: Findings are a high-pressure triage surface. If operators hit dead ends there, the broader governance workflow breaks.
|
||||
|
||||
**Independent Test**: Can be fully tested by opening a finding with related records and verifying that the finding detail and list surfaces expose clear links to the most important upstream evidence and downstream operational context.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a finding with an accessible related baseline snapshot and source run, **When** an authorized operator opens the finding, **Then** the page exposes clear related-context entries and drill-down actions to those records using human-readable labels.
|
||||
2. **Given** a findings list with rows that each have different related records available, **When** the operator reviews the list, **Then** each row exposes only the most relevant available drill-down actions and does not force the operator to open the record just to continue the workflow.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Move between policy history and snapshot evidence (Priority: P1)
|
||||
|
||||
As a policy operator, I want to move coherently between a policy, its versions, and related baseline snapshots so I can understand how captured evidence maps back to governance history.
|
||||
|
||||
**Why this priority**: Policy, version, and snapshot relationships are central to the platform’s governance model and should not require manual reconstruction.
|
||||
|
||||
**Independent Test**: Can be fully tested by opening a policy version and a baseline snapshot and verifying that each surface exposes the expected related parent, child, or filtered-list destinations.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a policy version that belongs to a parent policy and is referenced by accessible snapshots, **When** an authorized operator opens the policy version, **Then** the page offers a direct path back to the parent policy and to relevant snapshot evidence.
|
||||
2. **Given** a baseline snapshot tied to a baseline profile, source run, and policy-version evidence, **When** an authorized operator opens the snapshot, **Then** the page exposes those related records in a structured context section rather than raw identifiers or scattered text fields.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Preserve context when leaving tenant-scoped entry points (Priority: P2)
|
||||
|
||||
As an operator working from a tenant-context screen, I want canonical operations and monitoring pages to preserve my current tenant meaning so I can continue my investigation without losing scope.
|
||||
|
||||
**Why this priority**: Canonical routes reduce route sprawl, but they must not force the operator to mentally reconstruct which tenant the workflow came from.
|
||||
|
||||
**Independent Test**: Can be fully tested by navigating from a tenant-context page into a canonical operations destination and verifying that the resulting screen preserves tenant context through filters, badges, or visible metadata.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an operator launches a canonical operations view from a tenant-context resource, **When** the canonical page opens, **Then** the route remains the authoritative workspace-level destination and the originating tenant context is visibly preserved.
|
||||
2. **Given** an operator uses breadcrumbs or back links after drilling into a canonical operations destination, **When** they navigate back, **Then** the path reflects resource lineage or filtered-list origin rather than arbitrary browser-history behavior.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- A related record may be missing, deleted, or unresolved even though a stored reference exists.
|
||||
- A user may be allowed to view the current record but not the related destination.
|
||||
- A one-to-many relation may be more useful as a filtered canonical list than as a single-record deep link.
|
||||
- A record may have several technically available relations, but only a subset should be surfaced to avoid link sprawl.
|
||||
- A canonical operations destination may be reachable from both workspace-level and tenant-context entry points without changing the authoritative route.
|
||||
- Some screens may show technical identifiers as secondary context, but those identifiers must not be the only navigation affordance for high-value relationships.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature is a presentation and navigation cohesion improvement over existing governance, monitoring, inventory, and backup records. It introduces no new Microsoft Graph calls, no new write workflows, no new queue or scheduled jobs, and no new mutable domain records. Existing `OperationRun` and related evidence records remain authoritative; this feature only standardizes how users move between them.
|
||||
|
||||
**Constitution alignment (OPS-UX):** This feature reuses existing operational records as destinations only. It does not create or mutate `OperationRun` state, does not alter toast/progress/terminal notification behavior, and must not introduce operational side effects into read-only navigation surfaces.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** This feature affects workspace-admin `/admin` surfaces and tenant-context `/admin/t/{tenant}/...` entry points that launch related canonical views. Cross-plane access remains deny-as-not-found. Non-members or users outside entitled workspace or tenant scope must receive 404 semantics. In-scope members lacking target-resource capability must receive 403 semantics where protected resource access is attempted. Server-side gates or policies must continue to govern every related-resource destination. No raw capability strings or role-string checks may be introduced. At least one positive and one negative authorization test must cover related-resource visibility and navigation behavior.
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. This feature introduces no authentication handshake behavior and must not add synchronous outbound work to monitoring pages.
|
||||
|
||||
**Constitution alignment (BADGE-001):** If tenant or resource context badges are introduced or standardized as part of context-preserving navigation, they must use existing centralized badge semantics rather than ad hoc per-page mappings.
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** This feature modifies multiple Filament resource list and detail surfaces. The Action Surface Contract must remain satisfied by using consistent inspect affordances, capped visible row actions, and structured view-page related actions. No destructive actions are introduced by this feature.
|
||||
|
||||
**Constitution alignment (UX-001 — Layout & Information Architecture):** Modified detail pages must expose related context through sectioned view layouts rather than ad hoc field dumps. View pages remain infolist-first or equivalent read-only presentations. List pages continue to provide search, sort, and filter behavior for core dimensions while adding predictable related drill-down actions without overloading each row.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-131-01 Related navigation availability**: Whenever a related record is surfaced in a related-context section or as a drill-down action, the interface must provide either an actionable navigation path or a clear unavailable state for that specific displayed relation.
|
||||
- **FR-131-02 Human-readable relation labels**: Related records must be presented with human-readable labels whenever possible, with technical identifiers shown only as secondary context.
|
||||
- **FR-131-03 Canonical operations destinations**: All run-oriented or monitoring-oriented drill-down actions must resolve to the canonical workspace-level operations destination pattern.
|
||||
- **FR-131-04 Tenant-context preservation**: When a canonical destination is opened from a tenant-context source, the destination must preserve the originating tenant meaning through filters, badges, or visible context metadata without changing the authoritative route.
|
||||
- **FR-131-05 Structured related-context section**: Key detail pages must expose a structured related-context section that groups the most important connected records for the current object.
|
||||
- **FR-131-06 Detail-surface consistency**: Related-context sections must follow a stable pattern across key detail pages so operators can predict where to find upstream and downstream links.
|
||||
- **FR-131-07 List-level drill-down actions**: High-value list surfaces must expose direct row-level drill-down actions to the most relevant related records when those actions support common operator workflows.
|
||||
- **FR-131-08 Limited action noise**: Pages with multiple possible related links must prioritize the few most useful operator journeys and avoid surfacing every technical relation at once. Default priority order is: direct upstream evidence for the current record first, canonical run or operations context second when it explains provenance, and filtered list destinations for one-to-many follow-up third.
|
||||
- **FR-131-09 Shared action vocabulary**: Cross-resource navigation labels must converge on a small, consistent vocabulary such as “View policy,” “View policy version,” “View snapshot,” “View run,” and “Open operations.”
|
||||
- **FR-131-10 Dead-end reduction**: Key enterprise records including findings, baseline snapshots, policy versions, operation runs, and backup sets must not present as dead-end pages when meaningful related destinations exist.
|
||||
- **FR-131-11 Missing relation handling**: Missing, deleted, unresolved, or inaccessible related references must render as clear unavailable states without broken links or misleading affordances.
|
||||
- **FR-131-12 Role-aware action rendering**: Related-resource links and actions must only render as actionable when the user is permitted to open the target resource.
|
||||
- **FR-131-13 Non-leaking unavailable states**: Where policy allows awareness of a relation but not access to the target, the interface may show a non-clickable unavailable state without disclosing protected target detail.
|
||||
- **FR-131-14 Breadcrumb semantics**: Breadcrumbs and back links must support resource lineage and filtered-list origin semantics rather than relying only on browser history.
|
||||
- **FR-131-15 Explicit navigation matrix**: The implementation must define and follow an explicit cross-resource navigation matrix for the major policy, snapshot, finding, backup, and operations relationships in scope.
|
||||
- **FR-131-16 Policy and version navigation**: Users must be able to move coherently between policies and policy versions, including access to parent policy context from version surfaces.
|
||||
- **FR-131-17 Snapshot and profile navigation**: Users must be able to move between baseline snapshots and their owning baseline profiles, with source run context shown where available.
|
||||
- **FR-131-18 Snapshot and findings navigation**: Users must be able to move between snapshots and related findings where that relationship exists and is meaningful.
|
||||
- **FR-131-19 Finding evidence navigation**: Findings must expose direct paths to their most important related evidence or source artifacts where available.
|
||||
- **FR-131-20 Backup and run navigation**: Backup sets and related operation runs must expose coherent navigation in both directions where applicable.
|
||||
- **FR-131-21 Operations to domain navigation**: Canonical operations detail or filtered-list surfaces must provide drill-down paths back to the primary affected domain record when one exists.
|
||||
- **FR-131-22 Reusable relation presentation model**: Related links must be generated from a reusable presentation pattern so labels, availability, and context hints stay consistent across screens.
|
||||
- **FR-131-23 Centralized relation mapping**: Non-trivial cross-resource mapping rules must be centralized so future resources can plug into the same navigation pattern without page-by-page duplication.
|
||||
- **FR-131-24 Extensible future coverage**: The navigation pattern must remain extensible enough to support future related artifacts such as reports, evidence items, exceptions, review packs, and audit entries by allowing a new relation type to be added through the centralized matrix, label catalog, and shared related-context renderer without requiring page-specific label or availability logic changes on existing resources.
|
||||
|
||||
### Non-Goals
|
||||
|
||||
- Full information architecture or sidebar redesign
|
||||
- Global search redesign
|
||||
- New data models or relationship persistence changes
|
||||
- Audit log redesign
|
||||
- Baseline snapshot renderer redesign
|
||||
- Bulk workflow redesign
|
||||
- Notification redesign
|
||||
- Customer read-only portal behavior
|
||||
- Universal link exposure for every possible relation on every page
|
||||
|
||||
### Assumptions
|
||||
|
||||
- The current product already stores enough relationship metadata to support materially better drill-down behavior on the highest-value enterprise resources.
|
||||
- Canonical operations and monitoring destinations already exist or are being converged elsewhere and should be reused here rather than duplicated.
|
||||
- Operators benefit more from a predictable small set of high-value actions than from exhaustive exposure of every related record.
|
||||
- Some one-to-many relationships are best represented as filtered canonical lists rather than direct links to a single target record.
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Existing workspace and tenant authorization rules
|
||||
- Existing canonical operations and monitoring destination patterns
|
||||
- Existing governance, backup, inventory, and monitoring resource relationships
|
||||
- Existing related-record labels or display names where available
|
||||
|
||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
|
||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Findings | Workspace admin list and detail surfaces | None new beyond related view actions | Primary finding inspection remains the row or title link | View snapshot, View run | None | Existing empty state remains; no new CTA required | View snapshot, View policy or View run where most relevant | N/A | No | Related-context section added on detail view; no destructive actions |
|
||||
| Baseline snapshots | Workspace admin list and detail surfaces | None new beyond related view actions | Snapshot title or row remains primary inspect affordance | View profile, Open findings | None | Existing immutable empty state remains | View baseline profile, View run | N/A | No | Detail page gains structured related context |
|
||||
| Policy versions | Workspace admin list and detail surfaces | None new beyond related view actions | Version title or row remains primary inspect affordance | View policy, View snapshot | None | Existing empty state remains | View policy, Open snapshots | N/A | No | Focus is lineage and evidence navigation |
|
||||
| Backup sets | Workspace admin list and detail surfaces | None new beyond related view actions | Backup set row remains primary inspect affordance | View run, View artifact | None | Existing empty state remains | View run, Open operations | N/A | No | No backup mutation changes in this spec |
|
||||
| Operation runs and canonical operations pages | Canonical workspace operations list and detail surfaces | Filter and related navigation only | Canonical run inspection remains the primary row or detail action | View target record, View backup set when relevant | None | Existing empty state may include one canonical filter reset CTA if already standard | View related record, Back to related list context | N/A | No | Canonical route authority is the primary requirement |
|
||||
|
||||
### Navigation Matrix
|
||||
|
||||
- **NM-131-01 Policy ↔ Policy Version**: Policy surfaces expose versions or latest relevant version; policy version surfaces expose parent policy and may expose filtered sibling-version context where useful.
|
||||
- **NM-131-02 Policy Version ↔ Baseline Snapshot**: Policy version surfaces expose related snapshot evidence where supported; snapshot surfaces expose referenced policy version when resolvable and permitted.
|
||||
- **NM-131-03 Baseline Snapshot ↔ Baseline Profile**: Snapshot surfaces expose owning baseline profile; baseline profile surfaces expose snapshots or latest relevant snapshot where meaningful.
|
||||
- **NM-131-04 Baseline Snapshot ↔ Findings**: Snapshot surfaces expose related findings via filtered list or direct drill-down; finding surfaces expose the source snapshot where available.
|
||||
- **NM-131-05 Finding ↔ Source Artifact**: Finding surfaces expose the most relevant source artifact among snapshot, baseline profile, policy, policy version, or run depending on available context.
|
||||
- **NM-131-06 Backup Set ↔ Operation Run**: Backup set surfaces expose originating or recent related runs; run surfaces expose the related backup set or resulting artifact when applicable.
|
||||
- **NM-131-07 Operation Run ↔ Domain Resource**: Canonical run surfaces expose the primary affected or target domain record where meaningful; domain-resource surfaces expose the related run or canonical operations view.
|
||||
- **NM-131-08 Operations ↔ Tenant Context**: Tenant-scoped entry points open the canonical operations destination with tenant context visibly preserved rather than branching to alternate route families.
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Related Context Entry**: A normalized presentation of one important related record, including user-facing label, destination availability, relation type, and context hint.
|
||||
- **Drill-Down Action**: A standardized action that opens a directly related upstream, downstream, or canonical list destination from a list or detail surface.
|
||||
- **Navigation Matrix Rule**: The explicit definition of which related destination should be offered for a given resource relationship and in which UI surface.
|
||||
- **Canonical Destination Context**: The preserved workspace and tenant meaning applied when a user opens an authoritative operations or monitoring page from another screen.
|
||||
- **Unavailable Relation State**: The representation used when a related record exists conceptually but cannot be opened because it is missing, unresolved, or unauthorized.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-131-01 Reduced dead ends**: In acceptance testing of the in-scope high-value resources, operators can move to at least one meaningful related destination from every eligible detail page without copying identifiers manually.
|
||||
- **SC-131-02 Canonical operations consistency**: In navigation tests for run-related actions, 100% of in-scope “View run” and similar operational links resolve to the canonical workspace-level operations destination pattern.
|
||||
- **SC-131-03 Evidence traceability**: In finding and snapshot test scenarios, operators can reach the most important related evidence or source record in one direct action from the current screen.
|
||||
- **SC-131-04 Context preservation**: In tenant-context-to-canonical navigation tests, 100% of in-scope flows preserve visible tenant meaning on the canonical destination.
|
||||
- **SC-131-05 Graceful degradation**: In missing, unresolved, or unauthorized relation scenarios, 100% of tested screens render a clear unavailable state without broken links.
|
||||
- **SC-131-06 Label consistency**: In regression review of the in-scope surfaces, the shared action vocabulary is used consistently for equivalent drill-down tasks.
|
||||
|
||||
209
specs/131-cross-resource-navigation/tasks.md
Normal file
209
specs/131-cross-resource-navigation/tasks.md
Normal file
@ -0,0 +1,209 @@
|
||||
# Tasks: Cross-Resource Navigation & Drill-Down Cohesion (131)
|
||||
|
||||
**Input**: Design documents from `specs/131-cross-resource-navigation/` (`spec.md`, `plan.md`, `research.md`, `data-model.md`, `contracts/`, `quickstart.md`)
|
||||
**Prerequisites**: `specs/131-cross-resource-navigation/plan.md` (required), `specs/131-cross-resource-navigation/spec.md` (required for user stories)
|
||||
|
||||
**Tests**: REQUIRED (Pest) for all runtime behavior changes in this repo.
|
||||
**Operations**: No new `OperationRun` flow is introduced; this feature reuses existing operational records strictly as canonical destinations.
|
||||
**RBAC**: Preserve workspace and tenant isolation, deny-as-not-found 404 for non-members, 403 for in-scope members missing target capability, and canonical capability-registry usage only.
|
||||
**Filament UI**: This feature extends existing Filament resource and page surfaces only; keep inspect affordances explicit, visible row actions capped, and related context rendered through sectioned read-only layouts.
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Reconfirm the exact route, resource, and policy seams before building the shared navigation layer.
|
||||
|
||||
- [X] T001 Review canonical operations route and helper seams in `routes/web.php`, `app/Support/OperationRunLinks.php`, and `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`
|
||||
- [X] T002 [P] Review in-scope resource list and detail seams in `app/Filament/Resources/FindingResource.php`, `app/Filament/Resources/BaselineSnapshotResource.php`, `app/Filament/Resources/PolicyVersionResource.php`, and `app/Filament/Resources/BackupSetResource.php`
|
||||
- [X] T003 [P] Review existing authorization and context-preservation seams in `app/Policies/OperationRunPolicy.php`, `app/Policies/FindingPolicy.php`, and `app/Filament/Pages/Monitoring/Operations.php`
|
||||
- [X] T004 [P] Review current navigation and evidence test coverage in `tests/Feature/Findings/FindingWorkflowViewActionsTest.php`, `tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php`, `tests/Feature/Operations/TenantlessOperationRunViewerTest.php`, and `tests/Feature/Filament/PolicyVersionBaselineEvidenceVisibilityTest.php`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Build the shared navigation abstraction and rendering seam that every user story depends on.
|
||||
|
||||
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
|
||||
|
||||
- [X] T005 Create shared navigation value objects in `app/Support/Navigation/NavigationMatrixRule.php`, `app/Support/Navigation/RelatedContextEntry.php`, and `app/Support/Navigation/UnavailableRelationState.php`
|
||||
- [X] T006 Create the centralized navigation matrix and resolver in `app/Support/Navigation/CrossResourceNavigationMatrix.php`, `app/Support/Navigation/RelatedNavigationResolver.php`, and `app/Support/Navigation/RelatedActionLabelCatalog.php`
|
||||
- [X] T007 Create canonical context preservation helpers in `app/Support/Navigation/CanonicalNavigationContext.php` and `app/Support/OperationRunLinks.php`
|
||||
- [X] T008 [P] Create the reusable related-context rendering partial in `resources/views/filament/infolists/entries/related-context.blade.php`
|
||||
- [X] T009 [P] Add unit coverage for matrix resolution, label vocabulary, and unavailable-state handling in `tests/Unit/Support/RelatedNavigationResolverTest.php` and `tests/Unit/Support/RelatedActionLabelCatalogTest.php`
|
||||
- [X] T010 [P] Add unit coverage for canonical context preservation in `tests/Unit/Support/CanonicalNavigationContextTest.php`
|
||||
|
||||
**Checkpoint**: The repo has a single shared navigation-matrix and related-context abstraction that resource surfaces can consume consistently.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Trace a finding to its source (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Operators can move from a finding to its most important source evidence and run context without copying IDs or hitting a dead end.
|
||||
|
||||
**Independent Test**: Open a finding with related snapshot, policy, policy-version, and run context and verify that the list and detail surfaces expose actionable related navigation with correct 404/403-aware degradation.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [X] T011 [P] [US1] Add finding detail related-context coverage in `tests/Feature/Findings/FindingRelatedNavigationTest.php`
|
||||
- [X] T012 [P] [US1] Extend finding list drill-down coverage in `tests/Feature/Findings/FindingWorkflowRowActionsTest.php` and `tests/Feature/Findings/FindingWorkflowViewActionsTest.php`
|
||||
- [X] T013 [P] [US1] Add cross-resource authorization coverage for finding-linked destinations in `tests/Feature/Rbac/CrossResourceNavigationAuthorizationTest.php`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T014 [US1] Implement finding-specific matrix rules and resolver mappings in `app/Support/Navigation/CrossResourceNavigationMatrix.php` and `app/Support/Navigation/RelatedNavigationResolver.php`
|
||||
- [X] T015 [US1] Add a structured related-context section to finding detail in `app/Filament/Resources/FindingResource.php` and `resources/views/filament/infolists/entries/related-context.blade.php`
|
||||
- [X] T016 [US1] Add canonical run and source-evidence drill-down actions to the findings list in `app/Filament/Resources/FindingResource.php`
|
||||
- [X] T017 [US1] Wire finding unavailable-state rendering and secondary identifier fallbacks in `app/Filament/Resources/FindingResource.php` and `app/Support/Navigation/UnavailableRelationState.php`
|
||||
|
||||
**Checkpoint**: User Story 1 is complete when findings no longer strand operators on raw evidence IDs and always offer the most useful next investigation step when authorized.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Move between policy history and snapshot evidence (Priority: P1)
|
||||
|
||||
**Goal**: Operators can move coherently between policies, policy versions, baseline snapshots, baseline profiles, and related findings without reconstructing lineage manually.
|
||||
|
||||
**Independent Test**: Open a policy version and a baseline snapshot, then verify the pages and relevant list surfaces expose parent policy, profile, snapshot, and finding drill-downs with consistent labels and graceful unavailable states.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [X] T018 [P] [US2] Add policy-version related-navigation coverage in `tests/Feature/Filament/PolicyVersionRelatedNavigationTest.php`
|
||||
- [X] T019 [P] [US2] Add baseline-snapshot related-context coverage in `tests/Feature/Filament/BaselineSnapshotRelatedContextTest.php`
|
||||
- [X] T020 [P] [US2] Extend existing policy and snapshot evidence coverage in `tests/Feature/Filament/PolicyVersionBaselineEvidenceVisibilityTest.php` and `tests/Feature/Filament/BaselineSnapshotStructuredRenderingTest.php`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T021 [US2] Implement policy, policy-version, snapshot, and profile matrix rules in `app/Support/Navigation/CrossResourceNavigationMatrix.php` and `app/Support/Navigation/RelatedNavigationResolver.php`
|
||||
- [X] T022 [US2] Add parent-policy and snapshot drill-down behavior to policy-version list and detail surfaces in `app/Filament/Resources/PolicyVersionResource.php`
|
||||
- [X] T023 [US2] Add profile, finding, run, and policy-version related context to baseline snapshots in `app/Filament/Resources/BaselineSnapshotResource.php` and `resources/views/filament/infolists/entries/related-context.blade.php`
|
||||
- [X] T024 [US2] Align upstream and child navigation affordances on policy and baseline-profile surfaces in `app/Filament/Resources/PolicyResource.php`, `app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php`, and `app/Filament/Resources/BaselineProfileResource.php`
|
||||
|
||||
**Checkpoint**: User Story 2 is complete when policy history and snapshot evidence behave like one connected governance workflow instead of separate CRUD views.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Preserve context on canonical operations and backup/run flows (Priority: P2)
|
||||
|
||||
**Goal**: Tenant-context entry points and backup/run flows open canonical operations destinations without losing tenant meaning or forcing operators to backtrack through browser history.
|
||||
|
||||
**Independent Test**: Launch canonical operations from a tenant-context page and a backup-related page, then verify the destination route remains canonical, tenant context stays visible, and related domain objects remain reachable from the operations surfaces.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [X] T025 [P] [US3] Add canonical operations related-navigation coverage in `tests/Feature/Monitoring/OperationsRelatedNavigationTest.php`
|
||||
- [X] T026 [P] [US3] Extend tenantless run viewer coverage for tenant-context preservation plus breadcrumb and back-link lineage in `tests/Feature/Operations/TenantlessOperationRunViewerTest.php` and `tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php`
|
||||
- [X] T027 [P] [US3] Add backup-set to run navigation coverage in `tests/Feature/Filament/BackupSetRelatedNavigationTest.php`
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T028 [US3] Expand canonical context-preservation behavior plus explicit breadcrumb and back-link lineage for operations list and detail pages in `app/Support/Navigation/CanonicalNavigationContext.php`, `app/Filament/Pages/Monitoring/Operations.php`, and `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`
|
||||
- [X] T029 [US3] Refactor all in-scope `View run` links to canonical helpers in `app/Filament/Resources/FindingResource.php`, `app/Filament/Resources/BaselineSnapshotResource.php`, `app/Filament/Resources/BackupSetResource.php`, and `app/Filament/Pages/BaselineCompareLanding.php`
|
||||
- [X] T030 [US3] Add backup-set and target-resource related drill-downs to canonical run surfaces in `app/Support/OperationRunLinks.php` and `app/Filament/Resources/OperationRunResource.php`
|
||||
- [X] T031 [US3] Align backup-set detail and row actions with canonical run navigation and unavailable-state handling in `app/Filament/Resources/BackupSetResource.php` and `app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php`
|
||||
|
||||
**Checkpoint**: User Story 3 is complete when canonical operations pages preserve tenant meaning, backup/run flows work in both directions, and operators can return through explicit contextual navigation instead of history guesswork.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Final regression protection, consistency cleanup, and verification across all stories.
|
||||
|
||||
- [X] T032 [P] Audit and remove remaining raw ID-only and non-canonical relation renderings in `app/Filament/Resources/FindingResource.php`, `app/Filament/Resources/PolicyVersionResource.php`, `app/Filament/Resources/BaselineSnapshotResource.php`, `app/Filament/Resources/BackupSetResource.php`, and `app/Support/OperationRunLinks.php`
|
||||
- [X] T033 [P] Add regression coverage for shared label vocabulary and unavailable-state behavior in `tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php`, `tests/Feature/Rbac/CrossResourceNavigationAuthorizationTest.php`, and `tests/Feature/Findings/FindingRelatedNavigationTest.php`
|
||||
- [X] T034 Run focused Pest verification from `specs/131-cross-resource-navigation/quickstart.md`
|
||||
- [X] T035 Run formatting for changed files with `vendor/bin/sail bin pint --dirty --format agent`
|
||||
- [ ] T036 Validate the manual QA scenarios in `specs/131-cross-resource-navigation/quickstart.md`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies; can start immediately.
|
||||
- **Foundational (Phase 2)**: Depends on Setup; blocks all user stories.
|
||||
- **User Story 1 (Phase 3)**: Depends on Foundational completion.
|
||||
- **User Story 2 (Phase 4)**: Depends on Foundational completion and can proceed independently of US1 once the shared navigation layer exists.
|
||||
- **User Story 3 (Phase 5)**: Depends on Foundational completion and benefits from US1/US2 because the same resolver and label vocabulary will already be in place.
|
||||
- **Polish (Phase 6)**: Depends on all desired user stories being complete.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **User Story 1 (P1)**: First MVP slice; no dependency on other user stories.
|
||||
- **User Story 2 (P1)**: Independent of US1 after Foundational, though it reuses the same support layer and shared partial.
|
||||
- **User Story 3 (P2)**: Independent after Foundational, but gains efficiency once US1 and US2 establish the canonical shared navigation pattern across resource pages.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Tests should be added before or alongside implementation and must fail before the story is considered complete.
|
||||
- Navigation-matrix rules and resolver mappings should land before UI surface wiring.
|
||||
- Detail-page related context should be complete before list-level row actions are treated as stable for that story.
|
||||
- Authorization-aware unavailable states should be implemented before final regression verification.
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- Setup review tasks `T002`, `T003`, and `T004` can run in parallel.
|
||||
- In Foundational, `T008`, `T009`, and `T010` can run in parallel after the support-layer file layout is agreed.
|
||||
- In US1, `T011`, `T012`, and `T013` can run in parallel.
|
||||
- In US2, `T018`, `T019`, and `T020` can run in parallel.
|
||||
- In US3, `T025`, `T026`, and `T027` can run in parallel.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# Launch US1 test work in parallel:
|
||||
T011 tests/Feature/Findings/FindingRelatedNavigationTest.php
|
||||
T012 tests/Feature/Findings/FindingWorkflowRowActionsTest.php + tests/Feature/Findings/FindingWorkflowViewActionsTest.php
|
||||
T013 tests/Feature/Rbac/CrossResourceNavigationAuthorizationTest.php
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 2
|
||||
|
||||
```bash
|
||||
# Launch US2 test work in parallel:
|
||||
T018 tests/Feature/Filament/PolicyVersionRelatedNavigationTest.php
|
||||
T019 tests/Feature/Filament/BaselineSnapshotRelatedContextTest.php
|
||||
T020 tests/Feature/Filament/PolicyVersionBaselineEvidenceVisibilityTest.php + tests/Feature/Filament/BaselineSnapshotStructuredRenderingTest.php
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 3
|
||||
|
||||
```bash
|
||||
# Launch US3 test work in parallel:
|
||||
T025 tests/Feature/Monitoring/OperationsRelatedNavigationTest.php
|
||||
T026 tests/Feature/Operations/TenantlessOperationRunViewerTest.php + tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php
|
||||
T027 tests/Feature/Filament/BackupSetRelatedNavigationTest.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1 Only)
|
||||
|
||||
1. Complete Phase 1: Setup.
|
||||
2. Complete Phase 2: Foundational.
|
||||
3. Complete Phase 3: User Story 1.
|
||||
4. Validate findings-to-source navigation independently before expanding to the broader governance graph.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Ship US1 to eliminate the highest-value investigation dead ends on findings.
|
||||
2. Add US2 to connect policy history and snapshot evidence coherently.
|
||||
3. Add US3 to finish canonical operations context preservation and backup/run cohesion.
|
||||
|
||||
### Suggested MVP Scope
|
||||
|
||||
- MVP = Phases 1 through 3, then run the focused finding-navigation and authorization tests from `specs/131-cross-resource-navigation/quickstart.md`.
|
||||
|
||||
---
|
||||
|
||||
## Format Validation
|
||||
|
||||
- Every task follows the checklist format `- [ ] T### [P?] [US?] Description with file path`.
|
||||
- Setup, Foundational, and Polish phases intentionally omit story labels.
|
||||
- User story phases use `[US1]`, `[US2]`, and `[US3]` labels.
|
||||
- Parallel markers are used only where tasks can proceed independently without conflicting incomplete prerequisites.
|
||||
@ -6,6 +6,7 @@
|
||||
use App\Models\AlertDelivery;
|
||||
use App\Models\AlertDestination;
|
||||
use App\Models\AlertRule;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
@ -103,6 +104,51 @@ function alertDeliveryFilterIndicatorLabels($component): array
|
||||
->assertCanNotSeeTableRecords([$deliveryB]);
|
||||
});
|
||||
|
||||
it('filters deliveries by tenant_id for multi-tenant members', function (): void {
|
||||
[$user, $tenantA] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$tenantB = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $tenantA->workspace_id,
|
||||
]);
|
||||
|
||||
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
|
||||
|
||||
$destination = AlertDestination::factory()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'is_enabled' => true,
|
||||
]);
|
||||
|
||||
$rule = AlertRule::factory()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
]);
|
||||
|
||||
$deliveryA = AlertDelivery::factory()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'tenant_id' => (int) $tenantA->getKey(),
|
||||
'alert_rule_id' => (int) $rule->getKey(),
|
||||
'alert_destination_id' => (int) $destination->getKey(),
|
||||
'status' => AlertDelivery::STATUS_SENT,
|
||||
]);
|
||||
|
||||
$deliveryB = AlertDelivery::factory()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'tenant_id' => (int) $tenantB->getKey(),
|
||||
'alert_rule_id' => (int) $rule->getKey(),
|
||||
'alert_destination_id' => (int) $destination->getKey(),
|
||||
'status' => AlertDelivery::STATUS_SENT,
|
||||
]);
|
||||
|
||||
Livewire::test(ListAlertDeliveries::class)
|
||||
->assertCanSeeTableRecords([$deliveryA, $deliveryB])
|
||||
->filterTable('tenant_id', (string) $tenantA->getKey())
|
||||
->assertCanSeeTableRecords([$deliveryA])
|
||||
->assertCanNotSeeTableRecords([$deliveryB]);
|
||||
});
|
||||
|
||||
it('includes tenantless test deliveries in the list', function (): void {
|
||||
[$user] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Features\SupportTesting\Testable;
|
||||
@ -231,3 +232,62 @@ function getAlertDeliveryHeaderAction(Testable $component, string $name): ?Actio
|
||||
->assertCanSeeTableRecords([$tenantADelivery])
|
||||
->assertCanNotSeeTableRecords([$tenantBDelivery]);
|
||||
});
|
||||
|
||||
it('preselects the tenant filter when a tenant context exists', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(ListAlertDeliveries::class)
|
||||
->assertSet('tableFilters.tenant_id.value', (string) $tenant->getKey());
|
||||
});
|
||||
|
||||
it('scopes alert deliveries to the remembered tenant context when filament tenant is absent', function (): void {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
|
||||
$tenantB = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $tenantA->workspace_id,
|
||||
]);
|
||||
|
||||
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
|
||||
|
||||
$workspaceId = (int) $tenantA->workspace_id;
|
||||
|
||||
$rule = AlertRule::factory()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
]);
|
||||
|
||||
$destination = AlertDestination::factory()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
]);
|
||||
|
||||
$tenantADelivery = AlertDelivery::factory()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'tenant_id' => (int) $tenantA->getKey(),
|
||||
'alert_rule_id' => (int) $rule->getKey(),
|
||||
'alert_destination_id' => (int) $destination->getKey(),
|
||||
'status' => AlertDelivery::STATUS_SENT,
|
||||
]);
|
||||
|
||||
$tenantBDelivery = AlertDelivery::factory()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'tenant_id' => (int) $tenantB->getKey(),
|
||||
'alert_rule_id' => (int) $rule->getKey(),
|
||||
'alert_destination_id' => (int) $destination->getKey(),
|
||||
'status' => AlertDelivery::STATUS_SENT,
|
||||
]);
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
app(WorkspaceContext::class)->rememberLastTenantId($workspaceId, (int) $tenantA->getKey());
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => $workspaceId]);
|
||||
|
||||
Livewire::test(ListAlertDeliveries::class)
|
||||
->assertSet('tableFilters.tenant_id.value', (string) $tenantA->getKey())
|
||||
->assertCanSeeTableRecords([$tenantADelivery])
|
||||
->assertCanNotSeeTableRecords([$tenantBDelivery]);
|
||||
});
|
||||
|
||||
38
tests/Feature/Filament/BackupSetRelatedNavigationTest.php
Normal file
38
tests/Feature/Filament/BackupSetRelatedNavigationTest.php
Normal file
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\OperationRun;
|
||||
use Filament\Facades\Filament;
|
||||
|
||||
it('links backup sets to their canonical operations context', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'name' => 'Nightly backup',
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->for($tenant)->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'backup_set.add_policies',
|
||||
'context' => [
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->get(BackupSetResource::getUrl('view', ['record' => $backupSet], tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('Related context')
|
||||
->assertSee('Open operations')
|
||||
->assertSee('/admin/operations/'.$run->getKey(), false);
|
||||
|
||||
$this->get(BackupSetResource::getUrl('index', tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('View run')
|
||||
->assertSee('/admin/operations/'.$run->getKey(), false);
|
||||
});
|
||||
@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\BaselineProfileResource;
|
||||
use App\Filament\Resources\BaselineSnapshotResource;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineSnapshotItem;
|
||||
use App\Models\OperationRun;
|
||||
|
||||
it('shows upstream baseline navigation on snapshot pages', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'name' => 'Security Baseline',
|
||||
'active_snapshot_id' => null,
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
$run = OperationRun::factory()->for($tenant)->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'baseline_capture',
|
||||
'context' => [
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
],
|
||||
]);
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'meta_jsonb' => [
|
||||
'display_name' => 'Windows Lockdown',
|
||||
'version_reference' => [
|
||||
'policy_version_id' => 9999,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSee('Related context')
|
||||
->assertSee(BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin'), false)
|
||||
->assertSee('/admin/operations/'.$run->getKey(), false);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(BaselineSnapshotResource::getUrl(panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSee('View baseline profile')
|
||||
->assertSee(BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin'), false);
|
||||
});
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\BaselineProfileResource;
|
||||
use App\Filament\Resources\BaselineSnapshotResource;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
@ -87,6 +88,8 @@
|
||||
$this->actingAs($user)
|
||||
->get(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSee('Related context')
|
||||
->assertSee(BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin'), false)
|
||||
->assertSeeInOrder(['Coverage summary', 'Captured policy types', 'Technical detail'])
|
||||
->assertDontSee('No captured policy types are available in this snapshot.')
|
||||
->assertDontSee('No snapshot items were captured for this baseline snapshot.')
|
||||
@ -101,6 +104,7 @@
|
||||
$this->actingAs($user)
|
||||
->get(BaselineSnapshotResource::getUrl(panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSee('View baseline profile')
|
||||
->assertSee(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'))
|
||||
->assertDontSee('>View<', escape: false);
|
||||
});
|
||||
|
||||
@ -95,4 +95,10 @@
|
||||
Livewire::actingAs($user)
|
||||
->test(ListPolicyVersions::class)
|
||||
->assertCanSeeTableRecords([$backupVersion, $baselinePurposeVersion]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(PolicyVersionResource::getUrl('view', ['record' => $baselinePurposeVersion], tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('Related context')
|
||||
->assertSee('Baseline profile');
|
||||
});
|
||||
|
||||
@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\BaselineSnapshotResource;
|
||||
use App\Filament\Resources\PolicyResource;
|
||||
use App\Filament\Resources\PolicyVersionResource;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineSnapshotItem;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use Filament\Facades\Filament;
|
||||
|
||||
it('shows parent policy and snapshot evidence links for policy versions', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'name' => 'Security Baseline',
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'display_name' => 'Windows Lockdown',
|
||||
]);
|
||||
|
||||
$version = PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'version_number' => 4,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'meta_jsonb' => [
|
||||
'display_name' => 'Windows Lockdown',
|
||||
'version_reference' => [
|
||||
'policy_version_id' => (int) $version->getKey(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('Related context')
|
||||
->assertSee(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant), false)
|
||||
->assertSee(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'), false);
|
||||
|
||||
$this->get(PolicyVersionResource::getUrl('index', tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('View policy')
|
||||
->assertSee(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant), false);
|
||||
});
|
||||
79
tests/Feature/Findings/FindingRelatedNavigationTest.php
Normal file
79
tests/Feature/Findings/FindingRelatedNavigationTest.php
Normal file
@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\BaselineSnapshotResource;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||
use App\Support\Navigation\RelatedNavigationResolver;
|
||||
use Filament\Facades\Filament;
|
||||
|
||||
it('renders finding related context and list drill-down links', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'name' => 'Security Baseline',
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'display_name' => 'Windows Lockdown',
|
||||
]);
|
||||
|
||||
$version = PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'version_number' => 3,
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->for($tenant)->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'baseline_compare',
|
||||
]);
|
||||
|
||||
$finding = Finding::factory()->for($tenant)->create([
|
||||
'current_operation_run_id' => (int) $run->getKey(),
|
||||
'evidence_jsonb' => [
|
||||
'current' => [
|
||||
'policy_version_id' => (int) $version->getKey(),
|
||||
],
|
||||
'provenance' => [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'compare_operation_run_id' => (int) $run->getKey(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$relatedEntries = app(RelatedNavigationResolver::class)
|
||||
->detailEntries(CrossResourceNavigationMatrix::SOURCE_FINDING, $finding);
|
||||
|
||||
$primaryAction = app(RelatedNavigationResolver::class)
|
||||
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $finding);
|
||||
|
||||
$this->get(FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('Related context')
|
||||
->assertSee('Snapshot')
|
||||
->assertSee(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'), false)
|
||||
->assertSee(e($relatedEntries[1]['targetUrl']), false);
|
||||
|
||||
$this->get(FindingResource::getUrl('index', tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee($primaryAction?->actionLabel ?? '')
|
||||
->assertSee($primaryAction?->targetUrl ?? '', false);
|
||||
});
|
||||
@ -5,6 +5,8 @@
|
||||
use App\Filament\Pages\Monitoring\Operations;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@ -135,6 +137,51 @@
|
||||
->assertSee('TenantB');
|
||||
});
|
||||
|
||||
it('shows an explicit back-link when canonical context is present on the operations index', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$context = new CanonicalNavigationContext(
|
||||
sourceSurface: 'backup_set.detail_section',
|
||||
canonicalRouteName: 'admin.operations.index',
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
backLinkLabel: 'Back to backup set',
|
||||
backLinkUrl: '/admin/tenant/backup-sets/1',
|
||||
);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(OperationRunLinks::index($tenant, $context))
|
||||
->assertOk()
|
||||
->assertSee('Back to backup set')
|
||||
->assertSee('/admin/tenant/backup-sets/1', false);
|
||||
});
|
||||
|
||||
it('keeps the canonical back-link action after Livewire hydration on the operations index', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
|
||||
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
|
||||
|
||||
Livewire::withQueryParams([
|
||||
'nav' => [
|
||||
'source_surface' => 'finding.list_row',
|
||||
'canonical_route_name' => 'admin.operations.index',
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'back_label' => 'Back to findings',
|
||||
'back_url' => '/admin/findings?tenant='.$tenant->external_id,
|
||||
],
|
||||
])
|
||||
->actingAs($user)
|
||||
->test(Operations::class)
|
||||
->assertActionVisible('operate_hub_back_to_origin_operations');
|
||||
});
|
||||
|
||||
it('does not register legacy operation resource routes', function (): void {
|
||||
expect(Route::has('filament.admin.resources.operations.index'))->toBeFalse();
|
||||
expect(Route::has('filament.admin.resources.operations.view'))->toBeFalse();
|
||||
|
||||
44
tests/Feature/Monitoring/OperationsRelatedNavigationTest.php
Normal file
44
tests/Feature/Monitoring/OperationsRelatedNavigationTest.php
Normal file
@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
|
||||
it('uses explicit back-link lineage on canonical run detail', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'name' => 'Nightly backup',
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->for($tenant)->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'backup_set.add_policies',
|
||||
'context' => [
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
],
|
||||
]);
|
||||
|
||||
$context = new CanonicalNavigationContext(
|
||||
sourceSurface: 'backup_set.detail_section',
|
||||
canonicalRouteName: 'admin.operations.view',
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
backLinkLabel: 'Back to backup set',
|
||||
backLinkUrl: BackupSetResource::getUrl('view', ['record' => $backupSet], tenant: $tenant),
|
||||
);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(OperationRunLinks::tenantlessView($run, $context))
|
||||
->assertOk()
|
||||
->assertSee('Back to backup set')
|
||||
->assertSee(BackupSetResource::getUrl('view', ['record' => $backupSet], tenant: $tenant), false)
|
||||
->assertSee('Related context')
|
||||
->assertSee($backupSet->name);
|
||||
});
|
||||
@ -7,12 +7,15 @@
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OpsUx\RunDetailPolling;
|
||||
use App\Support\TenantRole;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
|
||||
beforeEach(function (): void {
|
||||
Http::preventStrayRequests();
|
||||
@ -144,6 +147,95 @@
|
||||
->assertSee($failureMessage);
|
||||
});
|
||||
|
||||
it('renders explicit back-link lineage when opened from a canonical source context', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
$tenant->users()->attach((int) $user->getKey(), [
|
||||
'role' => TenantRole::Owner->value,
|
||||
'source' => 'manual',
|
||||
'source_ref' => null,
|
||||
'created_by_user_id' => null,
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'type' => 'backup_set.add_policies',
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
]);
|
||||
|
||||
$context = new CanonicalNavigationContext(
|
||||
sourceSurface: 'backup_set.detail_section',
|
||||
canonicalRouteName: 'admin.operations.view',
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
backLinkLabel: 'Back to backup set',
|
||||
backLinkUrl: '/admin/tenant/backup-sets/1',
|
||||
);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||
->get(OperationRunLinks::tenantlessView($run, $context))
|
||||
->assertSuccessful()
|
||||
->assertSee('Back to backup set')
|
||||
->assertSee('/admin/tenant/backup-sets/1', false);
|
||||
});
|
||||
|
||||
it('keeps the explicit back-link action after Livewire hydration', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
$tenant->users()->attach((int) $user->getKey(), [
|
||||
'role' => TenantRole::Owner->value,
|
||||
'source' => 'manual',
|
||||
'source_ref' => null,
|
||||
'created_by_user_id' => null,
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'type' => 'baseline_compare',
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
]);
|
||||
|
||||
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]);
|
||||
session([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]);
|
||||
|
||||
Livewire::withQueryParams([
|
||||
'nav' => [
|
||||
'source_surface' => 'finding.detail_section',
|
||||
'canonical_route_name' => 'admin.operations.view',
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'back_label' => 'Back to finding',
|
||||
'back_url' => '/admin/findings/42?tenant='.$tenant->external_id,
|
||||
],
|
||||
])
|
||||
->actingAs($user)
|
||||
->test(\App\Filament\Pages\Operations\TenantlessOperationRunViewer::class, ['run' => $run])
|
||||
->assertActionVisible('operate_hub_back_to_origin_run_detail');
|
||||
});
|
||||
|
||||
it('renders shared polling markup for active tenantless runs', function (string $status, int $ageSeconds): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\Finding;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use Filament\Facades\Filament;
|
||||
|
||||
it('renders an unavailable state when the related snapshot exists but workspace access is denied', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'name' => 'Security Baseline',
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$finding = Finding::factory()->for($tenant)->create([
|
||||
'evidence_jsonb' => [
|
||||
'provenance' => [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$resolver = \Mockery::mock(WorkspaceCapabilityResolver::class);
|
||||
$resolver->shouldReceive('isMember')->andReturnTrue();
|
||||
$resolver->shouldReceive('can')->andReturnFalse();
|
||||
app()->instance(WorkspaceCapabilityResolver::class, $resolver);
|
||||
|
||||
$this->get(FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('Unavailable')
|
||||
->assertSee('current scope');
|
||||
});
|
||||
54
tests/Unit/Support/CanonicalNavigationContextTest.php
Normal file
54
tests/Unit/Support/CanonicalNavigationContextTest.php
Normal file
@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
it('serializes canonical navigation query data', function (): void {
|
||||
$context = new CanonicalNavigationContext(
|
||||
sourceSurface: 'finding.detail_section',
|
||||
canonicalRouteName: 'admin.operations.view',
|
||||
tenantId: 44,
|
||||
backLinkLabel: 'Back to finding',
|
||||
backLinkUrl: '/admin/findings/12',
|
||||
filterPayload: [
|
||||
'tableFilters' => [
|
||||
'tenant_id' => ['value' => '44'],
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
expect($context->toQuery())
|
||||
->toMatchArray([
|
||||
'tableFilters' => [
|
||||
'tenant_id' => ['value' => '44'],
|
||||
],
|
||||
'nav' => [
|
||||
'source_surface' => 'finding.detail_section',
|
||||
'canonical_route_name' => 'admin.operations.view',
|
||||
'tenant_id' => 44,
|
||||
'back_label' => 'Back to finding',
|
||||
'back_url' => '/admin/findings/12',
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('round trips from a request payload', function (): void {
|
||||
$request = Request::create('/admin/operations/12', 'GET', [
|
||||
'nav' => [
|
||||
'source_surface' => 'backup_set.detail_section',
|
||||
'canonical_route_name' => 'admin.operations.view',
|
||||
'tenant_id' => 22,
|
||||
'back_label' => 'Back to backup set',
|
||||
'back_url' => '/admin/backup-sets/8',
|
||||
],
|
||||
]);
|
||||
|
||||
$context = CanonicalNavigationContext::fromRequest($request);
|
||||
|
||||
expect($context)->not->toBeNull()
|
||||
->and($context?->tenantId)->toBe(22)
|
||||
->and($context?->backLinkLabel)->toBe('Back to backup set')
|
||||
->and($context?->backLinkUrl)->toBe('/admin/backup-sets/8');
|
||||
});
|
||||
21
tests/Unit/Support/RelatedActionLabelCatalogTest.php
Normal file
21
tests/Unit/Support/RelatedActionLabelCatalogTest.php
Normal file
@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\Navigation\RelatedActionLabelCatalog;
|
||||
|
||||
it('returns consistent entry and action labels', function (): void {
|
||||
$catalog = new RelatedActionLabelCatalog;
|
||||
|
||||
expect($catalog->entryLabel('baseline_snapshot'))->toBe('Snapshot')
|
||||
->and($catalog->actionLabel('baseline_snapshot'))->toBe('View snapshot')
|
||||
->and($catalog->entryLabel('source_run'))->toBe('Run')
|
||||
->and($catalog->actionLabel('operations'))->toBe('Open operations');
|
||||
});
|
||||
|
||||
it('returns safe unavailable copy', function (): void {
|
||||
$catalog = new RelatedActionLabelCatalog;
|
||||
|
||||
expect($catalog->unavailableMessage('parent_policy', 'unauthorized'))
|
||||
->toContain('current scope');
|
||||
});
|
||||
98
tests/Unit/Support/RelatedNavigationResolverTest.php
Normal file
98
tests/Unit/Support/RelatedNavigationResolverTest.php
Normal file
@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||
use App\Support\Navigation\RelatedNavigationResolver;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('resolves finding entries in matrix priority order', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'name' => 'Security Baseline',
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'display_name' => 'Windows Lockdown',
|
||||
]);
|
||||
|
||||
$version = PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'version_number' => 3,
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->for($tenant)->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'baseline_compare',
|
||||
]);
|
||||
|
||||
$finding = Finding::factory()->for($tenant)->create([
|
||||
'current_operation_run_id' => (int) $run->getKey(),
|
||||
'evidence_jsonb' => [
|
||||
'current' => [
|
||||
'policy_version_id' => (int) $version->getKey(),
|
||||
],
|
||||
'provenance' => [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'compare_operation_run_id' => (int) $run->getKey(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$entries = app(RelatedNavigationResolver::class)
|
||||
->detailEntries(CrossResourceNavigationMatrix::SOURCE_FINDING, $finding);
|
||||
|
||||
expect(collect($entries)->pluck('key')->take(4)->values()->all())
|
||||
->toBe(['baseline_snapshot', 'source_run', 'current_policy_version', 'parent_policy'])
|
||||
->and(collect($entries)->firstWhere('key', 'baseline_snapshot')['targetUrl'])
|
||||
->toContain('/admin/baseline-snapshots/')
|
||||
->and(collect($entries)->firstWhere('key', 'source_run')['targetUrl'])
|
||||
->toContain('/admin/operations/');
|
||||
});
|
||||
|
||||
it('picks the highest priority list action for backup sets', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$backupSet = \App\Models\BackupSet::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'name' => 'Nightly backup',
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->for($tenant)->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'backup_set.add_policies',
|
||||
'context' => [
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
],
|
||||
]);
|
||||
|
||||
$action = app(RelatedNavigationResolver::class)
|
||||
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_BACKUP_SET, $backupSet);
|
||||
|
||||
expect($action)->not->toBeNull()
|
||||
->and($action?->actionLabel)->toBe('View run')
|
||||
->and($action?->targetUrl)->toContain((string) $run->getKey());
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user