Compare commits
2 Commits
169-action
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| fdd3a85b64 | |||
| 37c6d0622c |
8
.github/agents/copilot-instructions.md
vendored
8
.github/agents/copilot-instructions.md
vendored
@ -116,6 +116,10 @@ ## Active Technologies
|
|||||||
- PostgreSQL unchanged; feature adds no persistence and relies on request-local in-memory state only (167-derived-state-memoization)
|
- PostgreSQL unchanged; feature adds no persistence and relies on request-local in-memory state only (167-derived-state-memoization)
|
||||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BaselineCompareStats`, `BaselineCompareSummaryAssessor`, `BaselineCompareLanding`, `BaselineCompareNow`, `NeedsAttention`, `BaselineCompareCoverageBanner`, and `RequestScopedDerivedStateStore` from Spec 167 (168-tenant-governance-aggregate-contract)
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BaselineCompareStats`, `BaselineCompareSummaryAssessor`, `BaselineCompareLanding`, `BaselineCompareNow`, `NeedsAttention`, `BaselineCompareCoverageBanner`, and `RequestScopedDerivedStateStore` from Spec 167 (168-tenant-governance-aggregate-contract)
|
||||||
- PostgreSQL unchanged; no new persistence, cache store, or durable summary artifac (168-tenant-governance-aggregate-contract)
|
- PostgreSQL unchanged; no new persistence, cache store, or durable summary artifac (168-tenant-governance-aggregate-contract)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `ActionSurfaceDeclaration`, `ActionSurfaceValidator`, `ActionSurfaceDiscovery`, `ActionSurfaceExemptions`, and Filament Tables / Actions APIs (169-action-surface-v11)
|
||||||
|
- PostgreSQL unchanged; no new persistence, cache store, queue payload, or durable artifac (169-action-surface-v11)
|
||||||
|
- PHP 8.4, Laravel 12, Livewire v4, Filament v5 + `laravel/framework`, `filament/filament`, `livewire/livewire`, `pestphp/pest` (170-system-operations-surface-alignment)
|
||||||
|
- PostgreSQL with existing `operation_runs` and `audit_logs` tables; no schema changes (170-system-operations-surface-alignment)
|
||||||
|
|
||||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -135,8 +139,8 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
|
- 170-system-operations-surface-alignment: Added PHP 8.4, Laravel 12, Livewire v4, Filament v5 + `laravel/framework`, `filament/filament`, `livewire/livewire`, `pestphp/pest`
|
||||||
|
- 169-action-surface-v11: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `ActionSurfaceDeclaration`, `ActionSurfaceValidator`, `ActionSurfaceDiscovery`, `ActionSurfaceExemptions`, and Filament Tables / Actions APIs
|
||||||
- 168-tenant-governance-aggregate-contract: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BaselineCompareStats`, `BaselineCompareSummaryAssessor`, `BaselineCompareLanding`, `BaselineCompareNow`, `NeedsAttention`, `BaselineCompareCoverageBanner`, and `RequestScopedDerivedStateStore` from Spec 167
|
- 168-tenant-governance-aggregate-contract: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BaselineCompareStats`, `BaselineCompareSummaryAssessor`, `BaselineCompareLanding`, `BaselineCompareNow`, `NeedsAttention`, `BaselineCompareCoverageBanner`, and `RequestScopedDerivedStateStore` from Spec 167
|
||||||
- 167-derived-state-memoization: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `ArtifactTruthPresenter`, `OperationUxPresenter`, `RelatedNavigationResolver`, `AppServiceProvider`, `BadgeCatalog`, `BadgeRenderer`, and current Filament resource/page seams
|
|
||||||
- 166-finding-governance-health: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `Finding`, `FindingException`, `FindingRiskGovernanceResolver`, `BadgeCatalog`, `BadgeRenderer`, `FilterOptionCatalog`, and tenant dashboard widgets
|
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
<!-- MANUAL ADDITIONS END -->
|
<!-- MANUAL ADDITIONS END -->
|
||||||
|
|||||||
@ -24,6 +24,7 @@
|
|||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
@ -68,7 +69,7 @@ class AuditLog extends Page implements HasTable
|
|||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog)
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog, ActionSurfaceType::HistoryAudit)
|
||||||
->withDefaults(new ActionSurfaceDefaults(
|
->withDefaults(new ActionSurfaceDefaults(
|
||||||
moreGroupLabel: 'More',
|
moreGroupLabel: 'More',
|
||||||
exportIsDefaultBulkActionForReadOnly: false,
|
exportIsDefaultBulkActionForReadOnly: false,
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
@ -46,7 +47,7 @@ class EvidenceOverview extends Page
|
|||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'The overview header exposes a clear-filters action when a tenant prefilter is active.')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'The overview header exposes a clear-filters action when a tenant prefilter is active.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The overview exposes a single drill-down link per row without a More menu.')
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The overview exposes a single drill-down link per row without a More menu.')
|
||||||
|
|||||||
@ -24,6 +24,7 @@
|
|||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
@ -69,7 +70,7 @@ class FindingExceptionsQueue extends Page implements HasTable
|
|||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog)
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog, ActionSurfaceType::QueueReview)
|
||||||
->withDefaults(new ActionSurfaceDefaults(
|
->withDefaults(new ActionSurfaceDefaults(
|
||||||
moreGroupLabel: 'More',
|
moreGroupLabel: 'More',
|
||||||
exportIsDefaultBulkActionForReadOnly: false,
|
exportIsDefaultBulkActionForReadOnly: false,
|
||||||
|
|||||||
@ -19,6 +19,7 @@
|
|||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
@ -57,7 +58,7 @@ class Operations extends Page implements HasForms, HasTable
|
|||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog)
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||||
->withDefaults(new ActionSurfaceDefaults(
|
->withDefaults(new ActionSurfaceDefaults(
|
||||||
moreGroupLabel: 'More',
|
moreGroupLabel: 'More',
|
||||||
exportIsDefaultBulkActionForReadOnly: false,
|
exportIsDefaultBulkActionForReadOnly: false,
|
||||||
|
|||||||
@ -22,6 +22,7 @@
|
|||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
@ -62,7 +63,7 @@ class ReviewRegister extends Page implements HasTable
|
|||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog)
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions provide a single Clear filters action for the canonical review register.')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions provide a single Clear filters action for the canonical review register.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The review register does not expose bulk actions in the first slice.')
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The review register does not expose bulk actions in the first slice.')
|
||||||
@ -192,9 +193,6 @@ public function table(Table $table): Table
|
|||||||
FilterPresets::dateRange('review_date', 'Review date', 'generated_at'),
|
FilterPresets::dateRange('review_date', 'Review date', 'generated_at'),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Action::make('view_review')
|
|
||||||
->label('View review')
|
|
||||||
->url(fn (TenantReview $record): string => TenantReviewResource::tenantScopedUrl('view', ['record' => $record], $record->tenant, 'tenant')),
|
|
||||||
Action::make('export_executive_pack')
|
Action::make('export_executive_pack')
|
||||||
->label('Export executive pack')
|
->label('Export executive pack')
|
||||||
->icon('heroicon-o-arrow-down-tray')
|
->icon('heroicon-o-arrow-down-tray')
|
||||||
|
|||||||
@ -19,6 +19,7 @@
|
|||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
@ -90,7 +91,7 @@ public static function canView(Model $record): bool
|
|||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::ListOnlyReadOnly)
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||||
->exempt(ActionSurfaceSlot::ListHeader, 'Read-only history list intentionally has no list-header actions.')
|
->exempt(ActionSurfaceSlot::ListHeader, 'Read-only history list intentionally has no list-header actions.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'No secondary row actions are exposed for read-only deliveries.')
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'No secondary row actions are exposed for read-only deliveries.')
|
||||||
|
|||||||
@ -32,6 +32,7 @@
|
|||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use DateTimeZone;
|
use DateTimeZone;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
@ -190,11 +191,11 @@ public static function canDeleteAny(): bool
|
|||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndEdit)
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndEdit, ActionSurfaceType::CrudListFirstResource)
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions include capability-gated create.')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions include capability-gated create.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Row-level secondary actions are grouped under "More".')
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Row-level secondary actions are grouped under "More" in workflow-first, destructive-last order.')
|
||||||
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
|
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More" in workflow-first, destructive-last order.')
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines a capability-gated empty-state create CTA.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines a capability-gated empty-state create CTA.')
|
||||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Edit page provides save/cancel controls.');
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'Edit page provides save/cancel controls.');
|
||||||
}
|
}
|
||||||
@ -570,6 +571,48 @@ public static function table(Table $table): Table
|
|||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN)
|
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN)
|
||||||
->apply(),
|
->apply(),
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Action::make('restore')
|
||||||
|
->label('Restore')
|
||||||
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
|
->color('success')
|
||||||
|
->visible(fn (BackupSchedule $record): bool => $record->trashed())
|
||||||
|
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
|
||||||
|
$record = static::resolveProtectedScheduleRecordOrFail($record);
|
||||||
|
|
||||||
|
Gate::authorize('restore', $record);
|
||||||
|
|
||||||
|
if (! $record->trashed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$record->restore();
|
||||||
|
|
||||||
|
if ($record->tenant instanceof Tenant) {
|
||||||
|
$auditLogger->log(
|
||||||
|
tenant: $record->tenant,
|
||||||
|
action: 'backup_schedule.restored',
|
||||||
|
resourceType: 'backup_schedule',
|
||||||
|
resourceId: (string) $record->getKey(),
|
||||||
|
status: 'success',
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'backup_schedule_id' => $record->getKey(),
|
||||||
|
'backup_schedule_name' => $record->name,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Backup schedule restored')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE)
|
||||||
|
->apply(),
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Action::make('archive')
|
Action::make('archive')
|
||||||
->label('Archive')
|
->label('Archive')
|
||||||
@ -614,48 +657,6 @@ public static function table(Table $table): Table
|
|||||||
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE)
|
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE)
|
||||||
->destructive()
|
->destructive()
|
||||||
->apply(),
|
->apply(),
|
||||||
UiEnforcement::forAction(
|
|
||||||
Action::make('restore')
|
|
||||||
->label('Restore')
|
|
||||||
->icon('heroicon-o-arrow-uturn-left')
|
|
||||||
->color('success')
|
|
||||||
->visible(fn (BackupSchedule $record): bool => $record->trashed())
|
|
||||||
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
|
|
||||||
$record = static::resolveProtectedScheduleRecordOrFail($record);
|
|
||||||
|
|
||||||
Gate::authorize('restore', $record);
|
|
||||||
|
|
||||||
if (! $record->trashed()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$record->restore();
|
|
||||||
|
|
||||||
if ($record->tenant instanceof Tenant) {
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $record->tenant,
|
|
||||||
action: 'backup_schedule.restored',
|
|
||||||
resourceType: 'backup_schedule',
|
|
||||||
resourceId: (string) $record->getKey(),
|
|
||||||
status: 'success',
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'backup_schedule_id' => $record->getKey(),
|
|
||||||
'backup_schedule_name' => $record->name,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Backup schedule restored')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
})
|
|
||||||
)
|
|
||||||
->preserveVisibility()
|
|
||||||
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE)
|
|
||||||
->apply(),
|
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Action::make('forceDelete')
|
Action::make('forceDelete')
|
||||||
->label('Force delete')
|
->label('Force delete')
|
||||||
|
|||||||
@ -32,6 +32,7 @@
|
|||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
@ -128,10 +129,10 @@ public static function canView(Model $record): bool
|
|||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::CrudListFirstResource)
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Create baseline profile (capability-gated).')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Create baseline profile (capability-gated).')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while edit and archive remain grouped under "More".')
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Edit leads and archive trails inside "More".')
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk mutations for baseline profiles in v1.')
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk mutations for baseline profiles in v1.')
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List defines empty-state create CTA.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List defines empty-state create CTA.')
|
||||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page provides capture + edit actions.');
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page provides capture + edit actions.');
|
||||||
|
|||||||
@ -23,6 +23,7 @@
|
|||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
@ -120,7 +121,7 @@ public static function canView(Model $record): bool
|
|||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||||
->exempt(ActionSurfaceSlot::ListHeader, 'Snapshots are created by capture runs; no list-header actions.')
|
->exempt(ActionSurfaceSlot::ListHeader, 'Snapshots are created by capture runs; no list-header actions.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Snapshots are immutable; rows navigate directly to the detail page.')
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Snapshots are immutable; rows navigate directly to the detail page.')
|
||||||
|
|||||||
@ -28,6 +28,7 @@
|
|||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
@ -108,7 +109,7 @@ public static function canView(Model $record): bool
|
|||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Create snapshot is available from the list header.')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Create snapshot is available from the list header.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes a Create snapshot CTA.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes a Create snapshot CTA.')
|
||||||
|
|||||||
@ -33,6 +33,7 @@
|
|||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||||
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
||||||
@ -67,7 +68,7 @@ class OperationRunResource extends Resource
|
|||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::RunLog)
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::RunLog, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||||
->withDefaults(new ActionSurfaceDefaults(
|
->withDefaults(new ActionSurfaceDefaults(
|
||||||
moreGroupLabel: 'More',
|
moreGroupLabel: 'More',
|
||||||
exportIsDefaultBulkActionForReadOnly: false,
|
exportIsDefaultBulkActionForReadOnly: false,
|
||||||
|
|||||||
@ -32,6 +32,7 @@
|
|||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
@ -97,11 +98,11 @@ public static function canViewAny(): bool
|
|||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::CrudListFirstResource)
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Sync from Intune.')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Sync from Intune.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".')
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More" in helper-first, workflow-next, destructive-last order.')
|
||||||
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
|
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More" in helper-first, workflow-next, destructive-last order.')
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines an empty-state CTA.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines an empty-state CTA.')
|
||||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page provides header actions when applicable.');
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page provides header actions when applicable.');
|
||||||
}
|
}
|
||||||
@ -492,107 +493,6 @@ public static function table(Table $table): Table
|
|||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
ActionGroup::make([
|
ActionGroup::make([
|
||||||
UiEnforcement::forTableAction(
|
|
||||||
Actions\Action::make('ignore')
|
|
||||||
->label('Ignore')
|
|
||||||
->icon('heroicon-o-trash')
|
|
||||||
->color('danger')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->visible(fn (Policy $record): bool => $record->ignored_at === null)
|
|
||||||
->action(function (Policy $record): void {
|
|
||||||
$record->ignore();
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Policy ignored')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
fn () => static::resolveTenantContextForCurrentPanel(),
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
|
||||||
->tooltip('You do not have permission to ignore policies.')
|
|
||||||
->preserveVisibility()
|
|
||||||
->apply(),
|
|
||||||
UiEnforcement::forTableAction(
|
|
||||||
Actions\Action::make('restore')
|
|
||||||
->label('Restore')
|
|
||||||
->icon('heroicon-o-arrow-uturn-left')
|
|
||||||
->color('success')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->visible(fn (Policy $record): bool => $record->ignored_at !== null)
|
|
||||||
->action(function (Policy $record): void {
|
|
||||||
$record->unignore();
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Policy restored')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
fn () => static::resolveTenantContextForCurrentPanel(),
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
|
||||||
->tooltip('You do not have permission to restore policies.')
|
|
||||||
->preserveVisibility()
|
|
||||||
->apply(),
|
|
||||||
UiEnforcement::forTableAction(
|
|
||||||
Actions\Action::make('sync')
|
|
||||||
->label('Sync')
|
|
||||||
->icon('heroicon-o-arrow-path')
|
|
||||||
->color('primary')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->visible(fn (Policy $record): bool => $record->ignored_at === null)
|
|
||||||
->action(function (Policy $record, HasTable $livewire): void {
|
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var OperationRunService $opService */
|
|
||||||
$opService = app(OperationRunService::class);
|
|
||||||
$opRun = $opService->ensureRun(
|
|
||||||
tenant: $tenant,
|
|
||||||
type: 'policy.sync_one',
|
|
||||||
inputs: [
|
|
||||||
'scope' => 'one',
|
|
||||||
'policy_id' => (int) $record->getKey(),
|
|
||||||
],
|
|
||||||
initiator: $user
|
|
||||||
);
|
|
||||||
|
|
||||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
|
||||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
SyncPoliciesJob::dispatch((int) $tenant->getKey(), null, [(int) $record->getKey()], $opRun);
|
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
|
||||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
fn () => static::resolveTenantContextForCurrentPanel(),
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_SYNC)
|
|
||||||
->preserveVisibility()
|
|
||||||
->apply(),
|
|
||||||
UiEnforcement::forTableAction(
|
UiEnforcement::forTableAction(
|
||||||
Actions\Action::make('export')
|
Actions\Action::make('export')
|
||||||
->label('Export to Backup')
|
->label('Export to Backup')
|
||||||
@ -663,12 +563,345 @@ public static function table(Table $table): Table
|
|||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->apply(),
|
->apply(),
|
||||||
|
UiEnforcement::forTableAction(
|
||||||
|
Actions\Action::make('sync')
|
||||||
|
->label('Sync')
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->color('primary')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->visible(fn (Policy $record): bool => $record->ignored_at === null)
|
||||||
|
->action(function (Policy $record, HasTable $livewire): void {
|
||||||
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var OperationRunService $opService */
|
||||||
|
$opService = app(OperationRunService::class);
|
||||||
|
$opRun = $opService->ensureRun(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'policy.sync_one',
|
||||||
|
inputs: [
|
||||||
|
'scope' => 'one',
|
||||||
|
'policy_id' => (int) $record->getKey(),
|
||||||
|
],
|
||||||
|
initiator: $user
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||||
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
|
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SyncPoliciesJob::dispatch((int) $tenant->getKey(), null, [(int) $record->getKey()], $opRun);
|
||||||
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
|
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_SYNC)
|
||||||
|
->preserveVisibility()
|
||||||
|
->apply(),
|
||||||
|
UiEnforcement::forTableAction(
|
||||||
|
Actions\Action::make('restore')
|
||||||
|
->label('Restore')
|
||||||
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
|
->color('success')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->visible(fn (Policy $record): bool => $record->ignored_at !== null)
|
||||||
|
->action(function (Policy $record): void {
|
||||||
|
$record->unignore();
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Policy restored')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
|
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->tooltip('You do not have permission to restore policies.')
|
||||||
|
->preserveVisibility()
|
||||||
|
->apply(),
|
||||||
|
UiEnforcement::forTableAction(
|
||||||
|
Actions\Action::make('ignore')
|
||||||
|
->label('Ignore')
|
||||||
|
->icon('heroicon-o-trash')
|
||||||
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->visible(fn (Policy $record): bool => $record->ignored_at === null)
|
||||||
|
->action(function (Policy $record): void {
|
||||||
|
$record->ignore();
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Policy ignored')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
|
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->tooltip('You do not have permission to ignore policies.')
|
||||||
|
->preserveVisibility()
|
||||||
|
->apply(),
|
||||||
])
|
])
|
||||||
->label('More')
|
->label('More')
|
||||||
->icon('heroicon-o-ellipsis-vertical'),
|
->icon('heroicon-o-ellipsis-vertical'),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
BulkActionGroup::make([
|
BulkActionGroup::make([
|
||||||
|
UiEnforcement::forBulkAction(
|
||||||
|
BulkAction::make('bulk_export')
|
||||||
|
->label('Export to Backup')
|
||||||
|
->icon('heroicon-o-archive-box-arrow-down')
|
||||||
|
->form([
|
||||||
|
Forms\Components\TextInput::make('backup_name')
|
||||||
|
->label('Backup Name')
|
||||||
|
->required()
|
||||||
|
->default(fn () => 'Backup '.now()->toDateTimeString()),
|
||||||
|
])
|
||||||
|
->action(function (Collection $records, array $data): void {
|
||||||
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
$user = auth()->user();
|
||||||
|
$count = $records->count();
|
||||||
|
$ids = $records->pluck('id')->toArray();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var BulkSelectionIdentity $selection */
|
||||||
|
$selection = app(BulkSelectionIdentity::class);
|
||||||
|
|
||||||
|
$selectionIdentity = $selection->fromIds($ids);
|
||||||
|
|
||||||
|
/** @var OperationRunService $runs */
|
||||||
|
$runs = app(OperationRunService::class);
|
||||||
|
|
||||||
|
$opRun = $runs->enqueueBulkOperation(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'policy.export',
|
||||||
|
targetScope: [
|
||||||
|
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||||
|
],
|
||||||
|
selectionIdentity: $selectionIdentity,
|
||||||
|
dispatcher: function ($operationRun) use ($tenant, $user, $ids, $data, $count): void {
|
||||||
|
if ($count >= 20) {
|
||||||
|
BulkPolicyExportJob::dispatch(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $user->getKey(),
|
||||||
|
policyIds: $ids,
|
||||||
|
backupName: (string) $data['backup_name'],
|
||||||
|
operationRun: $operationRun,
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
BulkPolicyExportJob::dispatchSync(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $user->getKey(),
|
||||||
|
policyIds: $ids,
|
||||||
|
backupName: (string) $data['backup_name'],
|
||||||
|
operationRun: $operationRun,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
initiator: $user,
|
||||||
|
extraContext: [
|
||||||
|
'backup_name' => (string) $data['backup_name'],
|
||||||
|
'policy_count' => $count,
|
||||||
|
],
|
||||||
|
emitQueuedNotification: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
->deselectRecordsAfterCompletion(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->apply(),
|
||||||
|
UiEnforcement::forBulkAction(
|
||||||
|
BulkAction::make('bulk_sync')
|
||||||
|
->label('Sync Policies')
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->color('primary')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->hidden(function (HasTable $livewire): bool {
|
||||||
|
$visibilityFilterState = $livewire->getTableFilterState('visibility') ?? [];
|
||||||
|
$value = $visibilityFilterState['value'] ?? null;
|
||||||
|
|
||||||
|
return $value === 'ignored';
|
||||||
|
})
|
||||||
|
->action(function (Collection $records, HasTable $livewire): void {
|
||||||
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ids = $records
|
||||||
|
->pluck('id')
|
||||||
|
->map(static fn ($id): int => (int) $id)
|
||||||
|
->unique()
|
||||||
|
->sort()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
/** @var OperationRunService $opService */
|
||||||
|
$opService = app(OperationRunService::class);
|
||||||
|
$opRun = $opService->ensureRun(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'policy.sync',
|
||||||
|
inputs: [
|
||||||
|
'scope' => 'subset',
|
||||||
|
'policy_ids' => $ids,
|
||||||
|
],
|
||||||
|
initiator: $user
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||||
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
|
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SyncPoliciesJob::dispatch((int) $tenant->getKey(), null, $ids, $opRun);
|
||||||
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
->deselectRecordsAfterCompletion(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_SYNC)
|
||||||
|
->apply(),
|
||||||
|
UiEnforcement::forBulkAction(
|
||||||
|
BulkAction::make('bulk_restore')
|
||||||
|
->label('Restore Policies')
|
||||||
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
|
->color('success')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->hidden(function (HasTable $livewire): bool {
|
||||||
|
$visibilityFilterState = $livewire->getTableFilterState('visibility') ?? [];
|
||||||
|
$value = $visibilityFilterState['value'] ?? null;
|
||||||
|
|
||||||
|
return ! in_array($value, [null, 'ignored'], true);
|
||||||
|
})
|
||||||
|
->action(function (Collection $records, HasTable $livewire): void {
|
||||||
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
$user = auth()->user();
|
||||||
|
$count = $records->count();
|
||||||
|
$ids = $records->pluck('id')->toArray();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var BulkSelectionIdentity $selection */
|
||||||
|
$selection = app(BulkSelectionIdentity::class);
|
||||||
|
|
||||||
|
$selectionIdentity = $selection->fromIds($ids);
|
||||||
|
|
||||||
|
/** @var OperationRunService $runs */
|
||||||
|
$runs = app(OperationRunService::class);
|
||||||
|
|
||||||
|
$opRun = $runs->enqueueBulkOperation(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'policy.unignore',
|
||||||
|
targetScope: [
|
||||||
|
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||||
|
],
|
||||||
|
selectionIdentity: $selectionIdentity,
|
||||||
|
dispatcher: function ($operationRun) use ($tenant, $user, $ids, $count): void {
|
||||||
|
if ($count >= 20) {
|
||||||
|
BulkPolicyUnignoreJob::dispatch(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $user->getKey(),
|
||||||
|
policyIds: $ids,
|
||||||
|
operationRun: $operationRun,
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
BulkPolicyUnignoreJob::dispatchSync(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $user->getKey(),
|
||||||
|
policyIds: $ids,
|
||||||
|
operationRun: $operationRun,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
initiator: $user,
|
||||||
|
extraContext: [
|
||||||
|
'policy_count' => $count,
|
||||||
|
],
|
||||||
|
emitQueuedNotification: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
|
|
||||||
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
->deselectRecordsAfterCompletion(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->apply(),
|
||||||
UiEnforcement::forBulkAction(
|
UiEnforcement::forBulkAction(
|
||||||
BulkAction::make('bulk_delete')
|
BulkAction::make('bulk_delete')
|
||||||
->label('Ignore Policies')
|
->label('Ignore Policies')
|
||||||
@ -766,242 +999,6 @@ public static function table(Table $table): Table
|
|||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
->apply(),
|
->apply(),
|
||||||
|
|
||||||
UiEnforcement::forBulkAction(
|
|
||||||
BulkAction::make('bulk_restore')
|
|
||||||
->label('Restore Policies')
|
|
||||||
->icon('heroicon-o-arrow-uturn-left')
|
|
||||||
->color('success')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->hidden(function (HasTable $livewire): bool {
|
|
||||||
$visibilityFilterState = $livewire->getTableFilterState('visibility') ?? [];
|
|
||||||
$value = $visibilityFilterState['value'] ?? null;
|
|
||||||
|
|
||||||
return ! in_array($value, [null, 'ignored'], true);
|
|
||||||
})
|
|
||||||
->action(function (Collection $records, HasTable $livewire): void {
|
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
|
||||||
$user = auth()->user();
|
|
||||||
$count = $records->count();
|
|
||||||
$ids = $records->pluck('id')->toArray();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var BulkSelectionIdentity $selection */
|
|
||||||
$selection = app(BulkSelectionIdentity::class);
|
|
||||||
|
|
||||||
$selectionIdentity = $selection->fromIds($ids);
|
|
||||||
|
|
||||||
/** @var OperationRunService $runs */
|
|
||||||
$runs = app(OperationRunService::class);
|
|
||||||
|
|
||||||
$opRun = $runs->enqueueBulkOperation(
|
|
||||||
tenant: $tenant,
|
|
||||||
type: 'policy.unignore',
|
|
||||||
targetScope: [
|
|
||||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
|
||||||
],
|
|
||||||
selectionIdentity: $selectionIdentity,
|
|
||||||
dispatcher: function ($operationRun) use ($tenant, $user, $ids, $count): void {
|
|
||||||
if ($count >= 20) {
|
|
||||||
BulkPolicyUnignoreJob::dispatch(
|
|
||||||
tenantId: (int) $tenant->getKey(),
|
|
||||||
userId: (int) $user->getKey(),
|
|
||||||
policyIds: $ids,
|
|
||||||
operationRun: $operationRun,
|
|
||||||
);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
BulkPolicyUnignoreJob::dispatchSync(
|
|
||||||
tenantId: (int) $tenant->getKey(),
|
|
||||||
userId: (int) $user->getKey(),
|
|
||||||
policyIds: $ids,
|
|
||||||
operationRun: $operationRun,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
initiator: $user,
|
|
||||||
extraContext: [
|
|
||||||
'policy_count' => $count,
|
|
||||||
],
|
|
||||||
emitQueuedNotification: false,
|
|
||||||
);
|
|
||||||
|
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
|
||||||
|
|
||||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
})
|
|
||||||
->deselectRecordsAfterCompletion(),
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
|
||||||
->apply(),
|
|
||||||
|
|
||||||
UiEnforcement::forBulkAction(
|
|
||||||
BulkAction::make('bulk_sync')
|
|
||||||
->label('Sync Policies')
|
|
||||||
->icon('heroicon-o-arrow-path')
|
|
||||||
->color('primary')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->hidden(function (HasTable $livewire): bool {
|
|
||||||
$visibilityFilterState = $livewire->getTableFilterState('visibility') ?? [];
|
|
||||||
$value = $visibilityFilterState['value'] ?? null;
|
|
||||||
|
|
||||||
return $value === 'ignored';
|
|
||||||
})
|
|
||||||
->action(function (Collection $records, HasTable $livewire): void {
|
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
|
||||||
$user = auth()->user();
|
|
||||||
$count = $records->count();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$ids = $records
|
|
||||||
->pluck('id')
|
|
||||||
->map(static fn ($id): int => (int) $id)
|
|
||||||
->unique()
|
|
||||||
->sort()
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
|
|
||||||
/** @var OperationRunService $opService */
|
|
||||||
$opService = app(OperationRunService::class);
|
|
||||||
$opRun = $opService->ensureRun(
|
|
||||||
tenant: $tenant,
|
|
||||||
type: 'policy.sync',
|
|
||||||
inputs: [
|
|
||||||
'scope' => 'subset',
|
|
||||||
'policy_ids' => $ids,
|
|
||||||
],
|
|
||||||
initiator: $user
|
|
||||||
);
|
|
||||||
|
|
||||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
|
||||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
SyncPoliciesJob::dispatch((int) $tenant->getKey(), null, $ids, $opRun);
|
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
|
||||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
})
|
|
||||||
->deselectRecordsAfterCompletion(),
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_SYNC)
|
|
||||||
->apply(),
|
|
||||||
|
|
||||||
UiEnforcement::forBulkAction(
|
|
||||||
BulkAction::make('bulk_export')
|
|
||||||
->label('Export to Backup')
|
|
||||||
->icon('heroicon-o-archive-box-arrow-down')
|
|
||||||
->form([
|
|
||||||
Forms\Components\TextInput::make('backup_name')
|
|
||||||
->label('Backup Name')
|
|
||||||
->required()
|
|
||||||
->default(fn () => 'Backup '.now()->toDateTimeString()),
|
|
||||||
])
|
|
||||||
->action(function (Collection $records, array $data): void {
|
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
|
||||||
$user = auth()->user();
|
|
||||||
$count = $records->count();
|
|
||||||
$ids = $records->pluck('id')->toArray();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var BulkSelectionIdentity $selection */
|
|
||||||
$selection = app(BulkSelectionIdentity::class);
|
|
||||||
|
|
||||||
$selectionIdentity = $selection->fromIds($ids);
|
|
||||||
|
|
||||||
/** @var OperationRunService $runs */
|
|
||||||
$runs = app(OperationRunService::class);
|
|
||||||
|
|
||||||
$opRun = $runs->enqueueBulkOperation(
|
|
||||||
tenant: $tenant,
|
|
||||||
type: 'policy.export',
|
|
||||||
targetScope: [
|
|
||||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
|
||||||
],
|
|
||||||
selectionIdentity: $selectionIdentity,
|
|
||||||
dispatcher: function ($operationRun) use ($tenant, $user, $ids, $data, $count): void {
|
|
||||||
if ($count >= 20) {
|
|
||||||
BulkPolicyExportJob::dispatch(
|
|
||||||
tenantId: (int) $tenant->getKey(),
|
|
||||||
userId: (int) $user->getKey(),
|
|
||||||
policyIds: $ids,
|
|
||||||
backupName: (string) $data['backup_name'],
|
|
||||||
operationRun: $operationRun,
|
|
||||||
);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
BulkPolicyExportJob::dispatchSync(
|
|
||||||
tenantId: (int) $tenant->getKey(),
|
|
||||||
userId: (int) $user->getKey(),
|
|
||||||
policyIds: $ids,
|
|
||||||
backupName: (string) $data['backup_name'],
|
|
||||||
operationRun: $operationRun,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
initiator: $user,
|
|
||||||
extraContext: [
|
|
||||||
'backup_name' => (string) $data['backup_name'],
|
|
||||||
'policy_count' => $count,
|
|
||||||
],
|
|
||||||
emitQueuedNotification: false,
|
|
||||||
);
|
|
||||||
|
|
||||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
})
|
|
||||||
->deselectRecordsAfterCompletion(),
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
|
||||||
->apply(),
|
|
||||||
])->label('More'),
|
])->label('More'),
|
||||||
])
|
])
|
||||||
->emptyStateHeading('No policies synced yet')
|
->emptyStateHeading('No policies synced yet')
|
||||||
|
|||||||
@ -20,6 +20,7 @@
|
|||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
@ -97,7 +98,7 @@ public static function canView(Model $record): bool
|
|||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Generate Pack action available in list header.')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Generate Pack action available in list header.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes Generate CTA.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes Generate CTA.')
|
||||||
|
|||||||
@ -49,6 +49,7 @@
|
|||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
@ -145,11 +146,11 @@ public static function canDeleteAny(): bool
|
|||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::CrudListFirstResource)
|
||||||
->withListRowPrimaryActionLimit(1)
|
->withListRowPrimaryActionLimit(1)
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'List page provides a capability-gated create action.')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'List page provides a capability-gated create action.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'At most one non-inspect row action stays primary; lifecycle-adjacent and destructive actions move under "More".')
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'At most one non-inspect row action stays primary; overflow keeps helpers first, workflow actions next, and destructive actions last.')
|
||||||
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
|
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Create action is reused in the list empty state.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Create action is reused in the list empty state.')
|
||||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Tenant view page exposes header actions via an action group.');
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'Tenant view page exposes header actions via an action group.');
|
||||||
@ -330,40 +331,37 @@ public static function table(Table $table): Table
|
|||||||
->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
|
->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
|
||||||
->visible(fn (Tenant $record): bool => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow) instanceof TenantActionDescriptor
|
->visible(fn (Tenant $record): bool => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow) instanceof TenantActionDescriptor
|
||||||
&& static::tenantIndexPrimaryAction($record)?->key !== 'related_onboarding'),
|
&& static::tenantIndexPrimaryAction($record)?->key !== 'related_onboarding'),
|
||||||
|
Actions\Action::make('openTenant')
|
||||||
|
->label('Open')
|
||||||
|
->icon('heroicon-o-arrow-right')
|
||||||
|
->color('primary')
|
||||||
|
->url(fn (Tenant $record) => \App\Filament\Resources\PolicyResource::getUrl('index', panel: 'tenant', tenant: $record))
|
||||||
|
->visible(fn (Tenant $record) => $record->isActive()),
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('restore')
|
Actions\Action::make('edit')
|
||||||
->label(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->label ?? 'Restore')
|
->label('Edit')
|
||||||
->color('success')
|
->icon('heroicon-o-pencil-square')
|
||||||
->icon(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-arrow-uturn-left')
|
->url(fn (Tenant $record) => static::getUrl('edit', ['record' => $record]))
|
||||||
->successNotificationTitle(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->successNotificationTitle ?? 'Tenant restored')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->modalHeading(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalHeading ?? 'Restore tenant')
|
|
||||||
->modalDescription(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalDescription ?? 'Restore this archived tenant to make it available again in normal management flows.')
|
|
||||||
->visible(fn (Tenant $record): bool => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->key === 'restore')
|
|
||||||
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
|
||||||
static::restoreTenant($record, $auditLogger);
|
|
||||||
})
|
|
||||||
)
|
)
|
||||||
->preserveVisibility()
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
->requireCapability(Capabilities::TENANT_DELETE)
|
|
||||||
->apply(),
|
->apply(),
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('archive')
|
Actions\Action::make('admin_consent')
|
||||||
->label(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->label ?? 'Archive')
|
->label('Grant admin consent')
|
||||||
->color('danger')
|
->icon('heroicon-o-clipboard-document')
|
||||||
->icon(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-archive-box-x-mark')
|
->url(fn (Tenant $record) => static::adminConsentUrl($record))
|
||||||
->successNotificationTitle(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->successNotificationTitle ?? 'Tenant archived')
|
->visible(fn (Tenant $record) => static::adminConsentUrl($record) !== null)
|
||||||
->requiresConfirmation()
|
->openUrlInNewTab(),
|
||||||
->modalHeading(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalHeading ?? 'Archive tenant')
|
|
||||||
->modalDescription(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalDescription ?? 'Archive this tenant to retain it for inspection while removing it from active operating flows.')
|
|
||||||
->visible(fn (Tenant $record): bool => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->key === 'archive')
|
|
||||||
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
|
||||||
static::archiveTenant($record, $auditLogger);
|
|
||||||
})
|
|
||||||
)
|
)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->requireCapability(Capabilities::TENANT_DELETE)
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
->apply(),
|
->apply(),
|
||||||
|
Actions\Action::make('open_in_entra')
|
||||||
|
->label('Open in Entra')
|
||||||
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
|
->url(fn (Tenant $record) => static::entraUrl($record))
|
||||||
|
->visible(fn (Tenant $record) => static::entraUrl($record) !== null)
|
||||||
|
->openUrlInNewTab(),
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('syncTenant')
|
Actions\Action::make('syncTenant')
|
||||||
->label('Sync')
|
->label('Sync')
|
||||||
@ -476,37 +474,6 @@ public static function table(Table $table): Table
|
|||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->requireCapability(Capabilities::TENANT_SYNC)
|
->requireCapability(Capabilities::TENANT_SYNC)
|
||||||
->apply(),
|
->apply(),
|
||||||
Actions\Action::make('openTenant')
|
|
||||||
->label('Open')
|
|
||||||
->icon('heroicon-o-arrow-right')
|
|
||||||
->color('primary')
|
|
||||||
->url(fn (Tenant $record) => \App\Filament\Resources\PolicyResource::getUrl('index', panel: 'tenant', tenant: $record))
|
|
||||||
->visible(fn (Tenant $record) => $record->isActive()),
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('edit')
|
|
||||||
->label('Edit')
|
|
||||||
->icon('heroicon-o-pencil-square')
|
|
||||||
->url(fn (Tenant $record) => static::getUrl('edit', ['record' => $record]))
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
|
||||||
->apply(),
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('admin_consent')
|
|
||||||
->label('Grant admin consent')
|
|
||||||
->icon('heroicon-o-clipboard-document')
|
|
||||||
->url(fn (Tenant $record) => static::adminConsentUrl($record))
|
|
||||||
->visible(fn (Tenant $record) => static::adminConsentUrl($record) !== null)
|
|
||||||
->openUrlInNewTab(),
|
|
||||||
)
|
|
||||||
->preserveVisibility()
|
|
||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
|
||||||
->apply(),
|
|
||||||
Actions\Action::make('open_in_entra')
|
|
||||||
->label('Open in Entra')
|
|
||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
|
||||||
->url(fn (Tenant $record) => static::entraUrl($record))
|
|
||||||
->visible(fn (Tenant $record) => static::entraUrl($record) !== null)
|
|
||||||
->openUrlInNewTab(),
|
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('verify')
|
Actions\Action::make('verify')
|
||||||
->label('Verify configuration')
|
->label('Verify configuration')
|
||||||
@ -632,6 +599,23 @@ public static function table(Table $table): Table
|
|||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->requireCapability(Capabilities::PROVIDER_RUN)
|
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||||
->apply(),
|
->apply(),
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('restore')
|
||||||
|
->label(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->label ?? 'Restore')
|
||||||
|
->color('success')
|
||||||
|
->icon(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-arrow-uturn-left')
|
||||||
|
->successNotificationTitle(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->successNotificationTitle ?? 'Tenant restored')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalHeading ?? 'Restore tenant')
|
||||||
|
->modalDescription(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalDescription ?? 'Restore this archived tenant to make it available again in normal management flows.')
|
||||||
|
->visible(fn (Tenant $record): bool => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->key === 'restore')
|
||||||
|
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
||||||
|
static::restoreTenant($record, $auditLogger);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_DELETE)
|
||||||
|
->apply(),
|
||||||
static::rbacAction(),
|
static::rbacAction(),
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('forceDelete')
|
Actions\Action::make('forceDelete')
|
||||||
@ -689,6 +673,23 @@ public static function table(Table $table): Table
|
|||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->requireCapability(Capabilities::TENANT_DELETE)
|
->requireCapability(Capabilities::TENANT_DELETE)
|
||||||
->apply(),
|
->apply(),
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('archive')
|
||||||
|
->label(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->label ?? 'Archive')
|
||||||
|
->color('danger')
|
||||||
|
->icon(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-archive-box-x-mark')
|
||||||
|
->successNotificationTitle(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->successNotificationTitle ?? 'Tenant archived')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalHeading ?? 'Archive tenant')
|
||||||
|
->modalDescription(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalDescription ?? 'Archive this tenant to retain it for inspection while removing it from active operating flows.')
|
||||||
|
->visible(fn (Tenant $record): bool => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->key === 'archive')
|
||||||
|
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
||||||
|
static::archiveTenant($record, $auditLogger);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_DELETE)
|
||||||
|
->apply(),
|
||||||
])
|
])
|
||||||
->label('More')
|
->label('More')
|
||||||
->icon('heroicon-o-ellipsis-vertical')
|
->icon('heroicon-o-ellipsis-vertical')
|
||||||
|
|||||||
@ -28,6 +28,7 @@
|
|||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
@ -115,7 +116,7 @@ public static function canView(Model $record): bool
|
|||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Create review is available from the review library header.')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Create review is available from the review library header.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes exactly one Create first review CTA.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes exactly one Create first review CTA.')
|
||||||
|
|||||||
@ -12,9 +12,11 @@
|
|||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
|
use Filament\Actions\ActionGroup;
|
||||||
use Filament\Forms;
|
use Filament\Forms;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
@ -94,10 +96,10 @@ public static function canEdit(Model $record): bool
|
|||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::CrudListFirstResource)
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'List page provides a capability-gated create action.')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'List page provides a capability-gated create action.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Workspace list intentionally uses only row-click inspection plus a primary Edit action.')
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while the secondary Edit shortcut lives under "More".')
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Workspace list intentionally omits bulk actions.')
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Workspace list intentionally omits bulk actions.')
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines a capability-gated empty-state create CTA.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines a capability-gated empty-state create CTA.')
|
||||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Workspace view page exposes a capability-gated edit action.');
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'Workspace view page exposes a capability-gated edit action.');
|
||||||
@ -161,12 +163,16 @@ public static function table(Table $table): Table
|
|||||||
->sortable(),
|
->sortable(),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
|
ActionGroup::make([
|
||||||
WorkspaceUiEnforcement::forTableAction(
|
WorkspaceUiEnforcement::forTableAction(
|
||||||
Actions\EditAction::make(),
|
Actions\EditAction::make(),
|
||||||
fn (): ?Workspace => null,
|
fn (): ?Workspace => null,
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::WORKSPACE_MANAGE)
|
->requireCapability(Capabilities::WORKSPACE_MANAGE)
|
||||||
->apply(),
|
->apply(),
|
||||||
|
])
|
||||||
|
->label('More')
|
||||||
|
->icon('heroicon-o-ellipsis-vertical'),
|
||||||
])
|
])
|
||||||
->emptyStateHeading('No workspaces')
|
->emptyStateHeading('No workspaces')
|
||||||
->emptyStateDescription('Create your first workspace.')
|
->emptyStateDescription('Create your first workspace.')
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Concerns\InteractsWithTable;
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
@ -37,7 +38,7 @@ class Tenants extends Page implements HasTable
|
|||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||||
->exempt(ActionSurfaceSlot::ListHeader, 'System tenant directory stays scan-first and does not expose page header actions.')
|
->exempt(ActionSurfaceSlot::ListHeader, 'System tenant directory stays scan-first and does not expose page header actions.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Tenant directory rows navigate directly to the detail page and have no secondary actions.')
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Tenant directory rows navigate directly to the detail page and have no secondary actions.')
|
||||||
|
|||||||
@ -18,6 +18,7 @@
|
|||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Concerns\InteractsWithTable;
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
@ -41,7 +42,7 @@ class Workspaces extends Page implements HasTable
|
|||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||||
->exempt(ActionSurfaceSlot::ListHeader, 'System workspace directory stays scan-first and does not expose page header actions.')
|
->exempt(ActionSurfaceSlot::ListHeader, 'System workspace directory stays scan-first and does not expose page header actions.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Workspace directory rows navigate directly to the detail page and have no secondary actions.')
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Workspace directory rows navigate directly to the detail page and have no secondary actions.')
|
||||||
|
|||||||
@ -6,22 +6,19 @@
|
|||||||
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\PlatformUser;
|
use App\Models\PlatformUser;
|
||||||
use App\Services\SystemConsole\OperationRunTriageService;
|
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\OperationCatalog;
|
use App\Support\OperationCatalog;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
|
||||||
use App\Support\System\SystemOperationRunLinks;
|
use App\Support\System\SystemOperationRunLinks;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Forms\Components\Textarea;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Concerns\InteractsWithTable;
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
@ -33,7 +30,9 @@ class Failures extends Page implements HasTable
|
|||||||
{
|
{
|
||||||
use InteractsWithTable;
|
use InteractsWithTable;
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Failures';
|
protected static ?string $navigationLabel = 'Failed operations';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Failed operations';
|
||||||
|
|
||||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-exclamation-triangle';
|
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-exclamation-triangle';
|
||||||
|
|
||||||
@ -45,11 +44,11 @@ class Failures extends Page implements HasTable
|
|||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog)
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||||
->exempt(ActionSurfaceSlot::ListHeader, 'System failures stay scan-first and rely on row triage rather than page header actions.')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'The page header exposes Show all operations while row clicks remain the only inspect model.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Failed-run triage stays per run and intentionally omits bulk actions.')
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Failed operations remain scan-first and intentionally omit bulk actions.')
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains when there are no failed runs to triage.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains when there are no failed operations and repeats the Show all operations CTA.')
|
||||||
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation opens the canonical system run detail page, which owns header actions.');
|
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation opens the canonical system run detail page, which owns header actions.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -85,6 +84,18 @@ public function mount(): void
|
|||||||
$this->mountInteractsWithTable();
|
$this->mountInteractsWithTable();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<Action>
|
||||||
|
*/
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Action::make('show_all_operations')
|
||||||
|
->label('Show all operations')
|
||||||
|
->url(SystemOperationRunLinks::index()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
@ -98,7 +109,7 @@ public function table(Table $table): Table
|
|||||||
})
|
})
|
||||||
->columns([
|
->columns([
|
||||||
TextColumn::make('id')
|
TextColumn::make('id')
|
||||||
->label('Run')
|
->label('ID')
|
||||||
->state(fn (OperationRun $record): string => '#'.$record->getKey()),
|
->state(fn (OperationRun $record): string => '#'.$record->getKey()),
|
||||||
TextColumn::make('status')
|
TextColumn::make('status')
|
||||||
->badge()
|
->badge()
|
||||||
@ -126,80 +137,15 @@ public function table(Table $table): Table
|
|||||||
TextColumn::make('created_at')->label('Started')->since(),
|
TextColumn::make('created_at')->label('Started')->since(),
|
||||||
])
|
])
|
||||||
->recordUrl(fn (OperationRun $record): string => SystemOperationRunLinks::view($record))
|
->recordUrl(fn (OperationRun $record): string => SystemOperationRunLinks::view($record))
|
||||||
->actions([
|
->actions([])
|
||||||
Action::make('retry')
|
->emptyStateHeading('No failed operations found')
|
||||||
->label('Retry')
|
->emptyStateDescription('Failed operations will appear here when a run completes unsuccessfully.')
|
||||||
->requiresConfirmation()
|
->emptyStateActions([
|
||||||
->visible(fn (OperationRun $record): bool => $this->canManageOperations() && app(OperationRunTriageService::class)->canRetry($record))
|
Action::make('show_all_operations_empty')
|
||||||
->action(function (OperationRun $record, OperationRunTriageService $triageService): void {
|
->label('Show all operations')
|
||||||
$user = $this->requireManageUser();
|
->url(SystemOperationRunLinks::index())
|
||||||
$retryRun = $triageService->retry($record, $user);
|
->button(),
|
||||||
|
|
||||||
OperationUxPresenter::queuedToast((string) $retryRun->type)
|
|
||||||
->actions([
|
|
||||||
\Filament\Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(SystemOperationRunLinks::view($retryRun)),
|
|
||||||
])
|
])
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
Action::make('cancel')
|
|
||||||
->label('Cancel')
|
|
||||||
->color('danger')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->visible(fn (OperationRun $record): bool => $this->canManageOperations() && app(OperationRunTriageService::class)->canCancel($record))
|
|
||||||
->action(function (OperationRun $record, OperationRunTriageService $triageService): void {
|
|
||||||
$user = $this->requireManageUser();
|
|
||||||
$triageService->cancel($record, $user);
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Run cancelled')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
Action::make('mark_investigated')
|
|
||||||
->label('Mark investigated')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->visible(fn (): bool => $this->canManageOperations())
|
|
||||||
->form([
|
|
||||||
Textarea::make('reason')
|
|
||||||
->label('Reason')
|
|
||||||
->required()
|
|
||||||
->minLength(5)
|
|
||||||
->maxLength(500)
|
|
||||||
->rows(4),
|
|
||||||
])
|
|
||||||
->action(function (OperationRun $record, array $data, OperationRunTriageService $triageService): void {
|
|
||||||
$user = $this->requireManageUser();
|
|
||||||
$triageService->markInvestigated($record, $user, (string) ($data['reason'] ?? ''));
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Run marked as investigated')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
->emptyStateHeading('No failed runs found')
|
|
||||||
->emptyStateDescription('Failed operations will appear here for triage.')
|
|
||||||
->bulkActions([]);
|
->bulkActions([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function canManageOperations(): bool
|
|
||||||
{
|
|
||||||
$user = auth('platform')->user();
|
|
||||||
|
|
||||||
return $user instanceof PlatformUser
|
|
||||||
&& $user->hasCapability(PlatformCapabilities::OPERATIONS_MANAGE);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function requireManageUser(): PlatformUser
|
|
||||||
{
|
|
||||||
$user = auth('platform')->user();
|
|
||||||
|
|
||||||
if (! $user instanceof PlatformUser || ! $user->hasCapability(PlatformCapabilities::OPERATIONS_MANAGE)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,20 +6,17 @@
|
|||||||
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\PlatformUser;
|
use App\Models\PlatformUser;
|
||||||
use App\Services\SystemConsole\OperationRunTriageService;
|
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\OperationCatalog;
|
use App\Support\OperationCatalog;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
|
||||||
use App\Support\System\SystemOperationRunLinks;
|
use App\Support\System\SystemOperationRunLinks;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Forms\Components\Textarea;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Concerns\InteractsWithTable;
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
@ -31,7 +28,9 @@ class Runs extends Page implements HasTable
|
|||||||
{
|
{
|
||||||
use InteractsWithTable;
|
use InteractsWithTable;
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Runs';
|
protected static ?string $navigationLabel = 'Operations';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Operations';
|
||||||
|
|
||||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-queue-list';
|
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-queue-list';
|
||||||
|
|
||||||
@ -43,11 +42,11 @@ class Runs extends Page implements HasTable
|
|||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog)
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||||
->exempt(ActionSurfaceSlot::ListHeader, 'System ops runs rely on inline row triage and do not expose page header actions.')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'The page header exposes Go to runbooks while row clicks remain the only inspect model.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'System ops triage stays per run and intentionally omits bulk actions.')
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'System operations remain scan-first and intentionally omit bulk actions.')
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains when no system runs have been queued yet.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains when no operations have been queued yet and repeats the Go to runbooks CTA.')
|
||||||
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation opens the canonical system run detail page, which owns header actions.');
|
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation opens the canonical system run detail page, which owns header actions.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -68,6 +67,18 @@ public function mount(): void
|
|||||||
$this->mountInteractsWithTable();
|
$this->mountInteractsWithTable();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<Action>
|
||||||
|
*/
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Action::make('go_to_runbooks')
|
||||||
|
->label('Go to runbooks')
|
||||||
|
->url(Runbooks::getUrl(panel: 'system')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
@ -79,7 +90,7 @@ public function table(Table $table): Table
|
|||||||
})
|
})
|
||||||
->columns([
|
->columns([
|
||||||
TextColumn::make('id')
|
TextColumn::make('id')
|
||||||
->label('Run')
|
->label('ID')
|
||||||
->state(fn (OperationRun $record): string => '#'.$record->getKey()),
|
->state(fn (OperationRun $record): string => '#'.$record->getKey()),
|
||||||
TextColumn::make('status')
|
TextColumn::make('status')
|
||||||
->badge()
|
->badge()
|
||||||
@ -108,80 +119,15 @@ public function table(Table $table): Table
|
|||||||
TextColumn::make('created_at')->label('Started')->since(),
|
TextColumn::make('created_at')->label('Started')->since(),
|
||||||
])
|
])
|
||||||
->recordUrl(fn (OperationRun $record): string => SystemOperationRunLinks::view($record))
|
->recordUrl(fn (OperationRun $record): string => SystemOperationRunLinks::view($record))
|
||||||
->actions([
|
->actions([])
|
||||||
Action::make('retry')
|
->emptyStateHeading('No operations yet')
|
||||||
->label('Retry')
|
->emptyStateDescription('Operations from all workspaces will appear here when they are queued.')
|
||||||
->requiresConfirmation()
|
->emptyStateActions([
|
||||||
->visible(fn (OperationRun $record): bool => $this->canManageOperations() && app(OperationRunTriageService::class)->canRetry($record))
|
Action::make('go_to_runbooks_empty')
|
||||||
->action(function (OperationRun $record, OperationRunTriageService $triageService): void {
|
->label('Go to runbooks')
|
||||||
$user = $this->requireManageUser();
|
->url(Runbooks::getUrl(panel: 'system'))
|
||||||
$retryRun = $triageService->retry($record, $user);
|
->button(),
|
||||||
|
|
||||||
OperationUxPresenter::queuedToast((string) $retryRun->type)
|
|
||||||
->actions([
|
|
||||||
\Filament\Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(SystemOperationRunLinks::view($retryRun)),
|
|
||||||
])
|
])
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
Action::make('cancel')
|
|
||||||
->label('Cancel')
|
|
||||||
->color('danger')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->visible(fn (OperationRun $record): bool => $this->canManageOperations() && app(OperationRunTriageService::class)->canCancel($record))
|
|
||||||
->action(function (OperationRun $record, OperationRunTriageService $triageService): void {
|
|
||||||
$user = $this->requireManageUser();
|
|
||||||
$triageService->cancel($record, $user);
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Run cancelled')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
Action::make('mark_investigated')
|
|
||||||
->label('Mark investigated')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->visible(fn (): bool => $this->canManageOperations())
|
|
||||||
->form([
|
|
||||||
Textarea::make('reason')
|
|
||||||
->label('Reason')
|
|
||||||
->required()
|
|
||||||
->minLength(5)
|
|
||||||
->maxLength(500)
|
|
||||||
->rows(4),
|
|
||||||
])
|
|
||||||
->action(function (OperationRun $record, array $data, OperationRunTriageService $triageService): void {
|
|
||||||
$user = $this->requireManageUser();
|
|
||||||
$triageService->markInvestigated($record, $user, (string) ($data['reason'] ?? ''));
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Run marked as investigated')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
->emptyStateHeading('No operation runs yet')
|
|
||||||
->emptyStateDescription('Runs from all workspaces will appear here when operations are queued.')
|
|
||||||
->bulkActions([]);
|
->bulkActions([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function canManageOperations(): bool
|
|
||||||
{
|
|
||||||
$user = auth('platform')->user();
|
|
||||||
|
|
||||||
return $user instanceof PlatformUser
|
|
||||||
&& $user->hasCapability(PlatformCapabilities::OPERATIONS_MANAGE);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function requireManageUser(): PlatformUser
|
|
||||||
{
|
|
||||||
$user = auth('platform')->user();
|
|
||||||
|
|
||||||
if (! $user instanceof PlatformUser || ! $user->hasCapability(PlatformCapabilities::OPERATIONS_MANAGE)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,22 +6,19 @@
|
|||||||
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\PlatformUser;
|
use App\Models\PlatformUser;
|
||||||
use App\Services\SystemConsole\OperationRunTriageService;
|
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\OperationCatalog;
|
use App\Support\OperationCatalog;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
|
||||||
use App\Support\System\SystemOperationRunLinks;
|
use App\Support\System\SystemOperationRunLinks;
|
||||||
use App\Support\SystemConsole\StuckRunClassifier;
|
use App\Support\SystemConsole\StuckRunClassifier;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Forms\Components\Textarea;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Concerns\InteractsWithTable;
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
@ -33,7 +30,9 @@ class Stuck extends Page implements HasTable
|
|||||||
{
|
{
|
||||||
use InteractsWithTable;
|
use InteractsWithTable;
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Stuck';
|
protected static ?string $navigationLabel = 'Stuck operations';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Stuck operations';
|
||||||
|
|
||||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-clock';
|
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-clock';
|
||||||
|
|
||||||
@ -45,11 +44,11 @@ class Stuck extends Page implements HasTable
|
|||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog)
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||||
->exempt(ActionSurfaceSlot::ListHeader, 'System stuck-run triage relies on row actions and does not expose page header actions.')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'The page header exposes Show all operations while row clicks remain the only inspect model.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Stuck-run triage stays per run and intentionally omits bulk actions.')
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Stuck operations remain scan-first and intentionally omit bulk actions.')
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains when no queued or running runs cross the stuck thresholds.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains when no operations cross the stuck thresholds and repeats the Show all operations CTA.')
|
||||||
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation opens the canonical system run detail page, which owns header actions.');
|
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation opens the canonical system run detail page, which owns header actions.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -84,6 +83,18 @@ public function mount(): void
|
|||||||
$this->mountInteractsWithTable();
|
$this->mountInteractsWithTable();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<Action>
|
||||||
|
*/
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Action::make('show_all_operations')
|
||||||
|
->label('Show all operations')
|
||||||
|
->url(SystemOperationRunLinks::index()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
@ -97,7 +108,7 @@ public function table(Table $table): Table
|
|||||||
})
|
})
|
||||||
->columns([
|
->columns([
|
||||||
TextColumn::make('id')
|
TextColumn::make('id')
|
||||||
->label('Run')
|
->label('ID')
|
||||||
->state(fn (OperationRun $record): string => '#'.$record->getKey()),
|
->state(fn (OperationRun $record): string => '#'.$record->getKey()),
|
||||||
TextColumn::make('status')
|
TextColumn::make('status')
|
||||||
->badge()
|
->badge()
|
||||||
@ -126,80 +137,15 @@ public function table(Table $table): Table
|
|||||||
TextColumn::make('created_at')->label('Started')->since(),
|
TextColumn::make('created_at')->label('Started')->since(),
|
||||||
])
|
])
|
||||||
->recordUrl(fn (OperationRun $record): string => SystemOperationRunLinks::view($record))
|
->recordUrl(fn (OperationRun $record): string => SystemOperationRunLinks::view($record))
|
||||||
->actions([
|
->actions([])
|
||||||
Action::make('retry')
|
->emptyStateHeading('No stuck operations found')
|
||||||
->label('Retry')
|
->emptyStateDescription('Queued and running operations that exceed the thresholds will appear here.')
|
||||||
->requiresConfirmation()
|
->emptyStateActions([
|
||||||
->visible(fn (OperationRun $record): bool => $this->canManageOperations() && app(OperationRunTriageService::class)->canRetry($record))
|
Action::make('show_all_operations_empty')
|
||||||
->action(function (OperationRun $record, OperationRunTriageService $triageService): void {
|
->label('Show all operations')
|
||||||
$user = $this->requireManageUser();
|
->url(SystemOperationRunLinks::index())
|
||||||
$retryRun = $triageService->retry($record, $user);
|
->button(),
|
||||||
|
|
||||||
OperationUxPresenter::queuedToast((string) $retryRun->type)
|
|
||||||
->actions([
|
|
||||||
\Filament\Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(SystemOperationRunLinks::view($retryRun)),
|
|
||||||
])
|
])
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
Action::make('cancel')
|
|
||||||
->label('Cancel')
|
|
||||||
->color('danger')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->visible(fn (OperationRun $record): bool => $this->canManageOperations() && app(OperationRunTriageService::class)->canCancel($record))
|
|
||||||
->action(function (OperationRun $record, OperationRunTriageService $triageService): void {
|
|
||||||
$user = $this->requireManageUser();
|
|
||||||
$triageService->cancel($record, $user);
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Run cancelled')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
Action::make('mark_investigated')
|
|
||||||
->label('Mark investigated')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->visible(fn (): bool => $this->canManageOperations())
|
|
||||||
->form([
|
|
||||||
Textarea::make('reason')
|
|
||||||
->label('Reason')
|
|
||||||
->required()
|
|
||||||
->minLength(5)
|
|
||||||
->maxLength(500)
|
|
||||||
->rows(4),
|
|
||||||
])
|
|
||||||
->action(function (OperationRun $record, array $data, OperationRunTriageService $triageService): void {
|
|
||||||
$user = $this->requireManageUser();
|
|
||||||
$triageService->markInvestigated($record, $user, (string) ($data['reason'] ?? ''));
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Run marked as investigated')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
->emptyStateHeading('No stuck runs found')
|
|
||||||
->emptyStateDescription('Queued and running runs outside thresholds will appear here.')
|
|
||||||
->bulkActions([]);
|
->bulkActions([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function canManageOperations(): bool
|
|
||||||
{
|
|
||||||
$user = auth('platform')->user();
|
|
||||||
|
|
||||||
return $user instanceof PlatformUser
|
|
||||||
&& $user->hasCapability(PlatformCapabilities::OPERATIONS_MANAGE);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function requireManageUser(): PlatformUser
|
|
||||||
{
|
|
||||||
$user = auth('platform')->user();
|
|
||||||
|
|
||||||
if (! $user instanceof PlatformUser || ! $user->hasCapability(PlatformCapabilities::OPERATIONS_MANAGE)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
use Filament\Forms\Components\Textarea;
|
use Filament\Forms\Components\Textarea;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
|
use Illuminate\Contracts\Support\Htmlable;
|
||||||
|
|
||||||
class ViewRun extends Page
|
class ViewRun extends Page
|
||||||
{
|
{
|
||||||
@ -44,12 +45,20 @@ public function mount(OperationRun $run): void
|
|||||||
$this->run = $run;
|
$this->run = $run;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getTitle(): string|Htmlable
|
||||||
|
{
|
||||||
|
return 'Operation #'.(int) $this->run->getKey();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<Action>
|
* @return array<Action>
|
||||||
*/
|
*/
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
Action::make('show_all_operations')
|
||||||
|
->label('Show all operations')
|
||||||
|
->url(SystemOperationRunLinks::index()),
|
||||||
Action::make('go_to_runbooks')
|
Action::make('go_to_runbooks')
|
||||||
->label('Go to runbooks')
|
->label('Go to runbooks')
|
||||||
->url(Runbooks::getUrl(panel: 'system')),
|
->url(Runbooks::getUrl(panel: 'system')),
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Concerns\InteractsWithTable;
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
@ -33,7 +34,7 @@ class AccessLogs extends Page implements HasTable
|
|||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog)
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog, ActionSurfaceType::HistoryAudit)
|
||||||
->exempt(ActionSurfaceSlot::ListHeader, 'Access logs remain scan-first and do not expose page header actions.')
|
->exempt(ActionSurfaceSlot::ListHeader, 'Access logs remain scan-first and do not expose page header actions.')
|
||||||
->exempt(ActionSurfaceSlot::InspectAffordance, 'Access logs intentionally keep auth and break-glass events inline without a separate inspect view.')
|
->exempt(ActionSurfaceSlot::InspectAffordance, 'Access logs intentionally keep auth and break-glass events inline without a separate inspect view.')
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Access logs are immutable and intentionally omit bulk actions.')
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Access logs are immutable and intentionally omit bulk actions.')
|
||||||
|
|||||||
@ -7,11 +7,16 @@
|
|||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceComponentType;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceComponentType;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||||
|
|
||||||
final class ActionSurfaceDeclaration
|
final class ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
|
private const int BEHAVIOR_AWARE_VERSION = 2;
|
||||||
|
|
||||||
private const string LIST_ROW_PRIMARY_ACTION_LIMIT = 'list_row_primary_action_limit';
|
private const string LIST_ROW_PRIMARY_ACTION_LIMIT = 'list_row_primary_action_limit';
|
||||||
|
|
||||||
|
private const string PRIMARY_LINK_COLUMN_REASON = 'primary_link_column_reason';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var array<string, ActionSurfaceSlotRequirement>
|
* @var array<string, ActionSurfaceSlotRequirement>
|
||||||
*/
|
*/
|
||||||
@ -33,6 +38,7 @@ public function __construct(
|
|||||||
public readonly int $version,
|
public readonly int $version,
|
||||||
public readonly ActionSurfaceComponentType $componentType,
|
public readonly ActionSurfaceComponentType $componentType,
|
||||||
public readonly ActionSurfaceProfile $profile,
|
public readonly ActionSurfaceProfile $profile,
|
||||||
|
public readonly ?ActionSurfaceType $surfaceType = null,
|
||||||
?ActionSurfaceDefaults $defaults = null,
|
?ActionSurfaceDefaults $defaults = null,
|
||||||
) {
|
) {
|
||||||
$this->defaults = $defaults ?? new ActionSurfaceDefaults;
|
$this->defaults = $defaults ?? new ActionSurfaceDefaults;
|
||||||
@ -41,28 +47,47 @@ public function __construct(
|
|||||||
public static function make(
|
public static function make(
|
||||||
ActionSurfaceComponentType $componentType,
|
ActionSurfaceComponentType $componentType,
|
||||||
ActionSurfaceProfile $profile,
|
ActionSurfaceProfile $profile,
|
||||||
|
?ActionSurfaceType $surfaceType = null,
|
||||||
int $version = 1,
|
int $version = 1,
|
||||||
): self {
|
): self {
|
||||||
return new self(
|
return new self(
|
||||||
version: $version,
|
version: self::normalizedVersion($surfaceType, $version),
|
||||||
componentType: $componentType,
|
componentType: $componentType,
|
||||||
profile: $profile,
|
profile: $profile,
|
||||||
|
surfaceType: $surfaceType,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function forResource(ActionSurfaceProfile $profile, int $version = 1): self
|
public static function forResource(
|
||||||
{
|
ActionSurfaceProfile $profile,
|
||||||
return self::make(ActionSurfaceComponentType::Resource, $profile, $version);
|
?ActionSurfaceType $surfaceType = null,
|
||||||
|
int $version = 1,
|
||||||
|
): self {
|
||||||
|
return self::make(ActionSurfaceComponentType::Resource, $profile, $surfaceType, $version);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function forPage(ActionSurfaceProfile $profile, int $version = 1): self
|
public static function forPage(
|
||||||
{
|
ActionSurfaceProfile $profile,
|
||||||
return self::make(ActionSurfaceComponentType::Page, $profile, $version);
|
?ActionSurfaceType $surfaceType = null,
|
||||||
|
int $version = 1,
|
||||||
|
): self {
|
||||||
|
return self::make(ActionSurfaceComponentType::Page, $profile, $surfaceType, $version);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function forRelationManager(ActionSurfaceProfile $profile, int $version = 1): self
|
public static function forRelationManager(
|
||||||
|
ActionSurfaceProfile $profile,
|
||||||
|
?ActionSurfaceType $surfaceType = null,
|
||||||
|
int $version = 1,
|
||||||
|
): self {
|
||||||
|
return self::make(ActionSurfaceComponentType::RelationManager, $profile, $surfaceType, $version);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function withSurfaceType(ActionSurfaceType $surfaceType): self
|
||||||
{
|
{
|
||||||
return self::make(ActionSurfaceComponentType::RelationManager, $profile, $version);
|
return $this->replicate(
|
||||||
|
surfaceType: $surfaceType,
|
||||||
|
version: self::normalizedVersion($surfaceType, $this->version),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function withDefaults(ActionSurfaceDefaults $defaults): self
|
public function withDefaults(ActionSurfaceDefaults $defaults): self
|
||||||
@ -133,6 +158,23 @@ public function listRowPrimaryActionLimit(): ?int
|
|||||||
return is_int($limit) ? $limit : null;
|
return is_int($limit) ? $limit : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function withPrimaryLinkColumnReason(string $reason): self
|
||||||
|
{
|
||||||
|
return $this->setMetadata(self::PRIMARY_LINK_COLUMN_REASON, $reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function primaryLinkColumnReason(): ?string
|
||||||
|
{
|
||||||
|
$reason = $this->metadata(self::PRIMARY_LINK_COLUMN_REASON);
|
||||||
|
|
||||||
|
return is_string($reason) ? $reason : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requiresBehaviorAwareContract(): bool
|
||||||
|
{
|
||||||
|
return $this->version >= self::BEHAVIOR_AWARE_VERSION;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, ActionSurfaceSlotRequirement>
|
* @return array<string, ActionSurfaceSlotRequirement>
|
||||||
*/
|
*/
|
||||||
@ -156,4 +198,30 @@ public function metadataValues(): array
|
|||||||
{
|
{
|
||||||
return $this->metadata;
|
return $this->metadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function normalizedVersion(?ActionSurfaceType $surfaceType, int $version): int
|
||||||
|
{
|
||||||
|
if (! $surfaceType instanceof ActionSurfaceType) {
|
||||||
|
return $version;
|
||||||
|
}
|
||||||
|
|
||||||
|
return max(self::BEHAVIOR_AWARE_VERSION, $version);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function replicate(?ActionSurfaceType $surfaceType, int $version): self
|
||||||
|
{
|
||||||
|
$declaration = new self(
|
||||||
|
version: $version,
|
||||||
|
componentType: $this->componentType,
|
||||||
|
profile: $this->profile,
|
||||||
|
surfaceType: $surfaceType,
|
||||||
|
defaults: $this->defaults,
|
||||||
|
);
|
||||||
|
|
||||||
|
$declaration->slots = $this->slots;
|
||||||
|
$declaration->exemptions = $this->exemptions;
|
||||||
|
$declaration->metadata = $this->metadata;
|
||||||
|
|
||||||
|
return $declaration;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceComponentType;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceComponentType;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfacePanelScope;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfacePanelScope;
|
||||||
|
use Filament\Tables\Contracts\HasTable;
|
||||||
use RecursiveDirectoryIterator;
|
use RecursiveDirectoryIterator;
|
||||||
use RecursiveIteratorIterator;
|
use RecursiveIteratorIterator;
|
||||||
use SplFileInfo;
|
use SplFileInfo;
|
||||||
@ -62,6 +63,20 @@ className: $className,
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach ($this->systemPageFiles() as $path) {
|
||||||
|
$className = $this->classNameFromPath($path);
|
||||||
|
|
||||||
|
if (! $this->isDeclaredSystemTablePage($className)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$components[$className] = new ActionSurfaceDiscoveredComponent(
|
||||||
|
className: $className,
|
||||||
|
componentType: ActionSurfaceComponentType::Page,
|
||||||
|
panelScopes: $this->panelScopesFor($className, $adminScopedClasses),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($this->relationManagerFiles() as $path) {
|
foreach ($this->relationManagerFiles() as $path) {
|
||||||
$className = $this->classNameFromPath($path);
|
$className = $this->classNameFromPath($path);
|
||||||
|
|
||||||
@ -124,6 +139,16 @@ private function pageFiles(): array
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function systemPageFiles(): array
|
||||||
|
{
|
||||||
|
return $this->collectPhpFiles($this->appPath.'/Filament/System/Pages', static function (string $path): bool {
|
||||||
|
return str_ends_with($path, '.php');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, string>
|
* @return array<int, string>
|
||||||
*/
|
*/
|
||||||
@ -193,6 +218,16 @@ private function discoverAdminScopedClasses(): array
|
|||||||
return $classes;
|
return $classes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function isDeclaredSystemTablePage(string $className): bool
|
||||||
|
{
|
||||||
|
if (! class_exists($className)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_subclass_of($className, HasTable::class)
|
||||||
|
&& method_exists($className, 'actionSurfaceDeclaration');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, string>
|
* @return array<int, string>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -19,6 +19,7 @@ public static function baseline(): self
|
|||||||
{
|
{
|
||||||
return new self(array_merge([
|
return new self(array_merge([
|
||||||
// Baseline allowlist for legacy surfaces. Keep shrinking this list.
|
// Baseline allowlist for legacy surfaces. Keep shrinking this list.
|
||||||
|
// Declared system table pages are discovered directly; deferred system tooling stays out of scope by not opting in.
|
||||||
'App\\Filament\\Pages\\Auth\\Login' => 'Auth entry page is out-of-scope for action-surface retrofits in spec 082.',
|
'App\\Filament\\Pages\\Auth\\Login' => 'Auth entry page is out-of-scope for action-surface retrofits in spec 082.',
|
||||||
'App\\Filament\\Pages\\BreakGlassRecovery' => 'Break-glass flow is governed by dedicated security specs and tests.',
|
'App\\Filament\\Pages\\BreakGlassRecovery' => 'Break-glass flow is governed by dedicated security specs and tests.',
|
||||||
'App\\Filament\\Pages\\ChooseTenant' => 'Tenant chooser has no contract-style table action surface.',
|
'App\\Filament\\Pages\\ChooseTenant' => 'Tenant chooser has no contract-style table action surface.',
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||||
|
|
||||||
final class ActionSurfaceProfileDefinition
|
final class ActionSurfaceProfileDefinition
|
||||||
{
|
{
|
||||||
@ -55,4 +56,36 @@ public function requiresExportDefaultBulk(ActionSurfaceProfile $profile): bool
|
|||||||
ActionSurfaceProfile::RunLog,
|
ActionSurfaceProfile::RunLog,
|
||||||
], true);
|
], true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, ActionSurfaceType>
|
||||||
|
*/
|
||||||
|
public function allowedSurfaceTypes(ActionSurfaceProfile $profile): array
|
||||||
|
{
|
||||||
|
return match ($profile) {
|
||||||
|
ActionSurfaceProfile::CrudListAndEdit,
|
||||||
|
ActionSurfaceProfile::CrudListAndView => [
|
||||||
|
ActionSurfaceType::CrudListFirstResource,
|
||||||
|
ActionSurfaceType::ReadOnlyRegistryReport,
|
||||||
|
ActionSurfaceType::ConfigLite,
|
||||||
|
],
|
||||||
|
ActionSurfaceProfile::ListOnlyReadOnly => [
|
||||||
|
ActionSurfaceType::ReadOnlyRegistryReport,
|
||||||
|
],
|
||||||
|
ActionSurfaceProfile::RunLog => [
|
||||||
|
ActionSurfaceType::ReadOnlyRegistryReport,
|
||||||
|
ActionSurfaceType::QueueReview,
|
||||||
|
ActionSurfaceType::HistoryAudit,
|
||||||
|
],
|
||||||
|
ActionSurfaceProfile::RelationManager => [
|
||||||
|
ActionSurfaceType::CrudListFirstResource,
|
||||||
|
ActionSurfaceType::ReadOnlyRegistryReport,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function allowsSurfaceType(ActionSurfaceProfile $profile, ActionSurfaceType $surfaceType): bool
|
||||||
|
{
|
||||||
|
return in_array($surfaceType, $this->allowedSurfaceTypes($profile), true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -95,6 +95,7 @@ className: $component->className,
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->validateRequiredSlots($component->className, $declaration, $issues);
|
$this->validateRequiredSlots($component->className, $declaration, $issues);
|
||||||
|
$this->validateBehaviorAwareContract($component->className, $declaration, $issues);
|
||||||
$this->validateExemptions($component->className, $declaration, $issues);
|
$this->validateExemptions($component->className, $declaration, $issues);
|
||||||
$this->validateExportDefaults($component->className, $declaration, $issues);
|
$this->validateExportDefaults($component->className, $declaration, $issues);
|
||||||
}
|
}
|
||||||
@ -222,6 +223,93 @@ private function validateInspectAffordanceSlot(
|
|||||||
ActionSurfaceSlotRequirement $requirement,
|
ActionSurfaceSlotRequirement $requirement,
|
||||||
array &$issues,
|
array &$issues,
|
||||||
): void {
|
): void {
|
||||||
|
$this->resolveInspectAffordance($className, $requirement, $issues);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, ActionSurfaceValidationIssue> $issues
|
||||||
|
*/
|
||||||
|
private function validateBehaviorAwareContract(
|
||||||
|
string $className,
|
||||||
|
ActionSurfaceDeclaration $declaration,
|
||||||
|
array &$issues,
|
||||||
|
): void {
|
||||||
|
if (! $declaration->requiresBehaviorAwareContract()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($declaration->surfaceType === null) {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
message: 'Behavior-aware declarations must define a surface type.',
|
||||||
|
hint: 'Pass an ActionSurfaceType when creating the declaration.',
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->profileDefinition->allowsSurfaceType($declaration->profile, $declaration->surfaceType)) {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
message: sprintf(
|
||||||
|
'Surface type "%s" is incompatible with profile "%s".',
|
||||||
|
$declaration->surfaceType->value,
|
||||||
|
$declaration->profile->value,
|
||||||
|
),
|
||||||
|
hint: 'Choose a surface type allowed for the profile or change the profile to match the rendered list behavior.',
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$requirement = $declaration->slot(ActionSurfaceSlot::InspectAffordance);
|
||||||
|
|
||||||
|
if (! $requirement instanceof ActionSurfaceSlotRequirement || $requirement->isExempt()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$affordance = $this->resolveInspectAffordance($className, $requirement, $issues);
|
||||||
|
|
||||||
|
if (! $affordance instanceof ActionSurfaceInspectAffordance) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $declaration->surfaceType->allowsInspectAffordance($affordance)) {
|
||||||
|
$allowed = implode(', ', array_map(
|
||||||
|
static fn (ActionSurfaceInspectAffordance $allowedAffordance): string => $allowedAffordance->value,
|
||||||
|
$declaration->surfaceType->allowedInspectAffordances(),
|
||||||
|
));
|
||||||
|
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
slot: ActionSurfaceSlot::InspectAffordance,
|
||||||
|
message: sprintf(
|
||||||
|
'Inspect affordance "%s" is incompatible with surface type "%s".',
|
||||||
|
$affordance->value,
|
||||||
|
$declaration->surfaceType->value,
|
||||||
|
),
|
||||||
|
hint: sprintf('Allowed: %s.', $allowed),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($affordance->isPrimaryLinkColumn() && trim((string) $declaration->primaryLinkColumnReason()) === '') {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
slot: ActionSurfaceSlot::InspectAffordance,
|
||||||
|
message: 'Primary link column inspect affordance requires a non-empty reason.',
|
||||||
|
hint: 'Call ->withPrimaryLinkColumnReason("why row click is not the right primary inspect model").',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, ActionSurfaceValidationIssue> $issues
|
||||||
|
*/
|
||||||
|
private function resolveInspectAffordance(
|
||||||
|
string $className,
|
||||||
|
ActionSurfaceSlotRequirement $requirement,
|
||||||
|
array &$issues,
|
||||||
|
): ?ActionSurfaceInspectAffordance {
|
||||||
$mode = $requirement->details;
|
$mode = $requirement->details;
|
||||||
|
|
||||||
if (! is_string($mode) || trim($mode) === '') {
|
if (! is_string($mode) || trim($mode) === '') {
|
||||||
@ -232,11 +320,13 @@ className: $className,
|
|||||||
hint: 'Use ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value).',
|
hint: 'Use ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value).',
|
||||||
);
|
);
|
||||||
|
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ActionSurfaceInspectAffordance::tryFrom($mode) instanceof ActionSurfaceInspectAffordance) {
|
$affordance = ActionSurfaceInspectAffordance::tryFrom($mode);
|
||||||
return;
|
|
||||||
|
if ($affordance instanceof ActionSurfaceInspectAffordance) {
|
||||||
|
return $affordance;
|
||||||
}
|
}
|
||||||
|
|
||||||
$issues[] = new ActionSurfaceValidationIssue(
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
@ -245,6 +335,8 @@ className: $className,
|
|||||||
message: sprintf('Invalid inspect affordance mode "%s".', $mode),
|
message: sprintf('Invalid inspect affordance mode "%s".', $mode),
|
||||||
hint: 'Allowed: clickable_row, view_action, primary_link_column.',
|
hint: 'Allowed: clickable_row, view_action, primary_link_column.',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -9,4 +9,19 @@ enum ActionSurfaceInspectAffordance: string
|
|||||||
case ClickableRow = 'clickable_row';
|
case ClickableRow = 'clickable_row';
|
||||||
case ViewAction = 'view_action';
|
case ViewAction = 'view_action';
|
||||||
case PrimaryLinkColumn = 'primary_link_column';
|
case PrimaryLinkColumn = 'primary_link_column';
|
||||||
|
|
||||||
|
public function isExplicitInspect(): bool
|
||||||
|
{
|
||||||
|
return $this === self::ViewAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isPrimaryLinkColumn(): bool
|
||||||
|
{
|
||||||
|
return $this === self::PrimaryLinkColumn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isSingleClickOpen(): bool
|
||||||
|
{
|
||||||
|
return $this === self::ClickableRow || $this === self::PrimaryLinkColumn;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
60
app/Support/Ui/ActionSurface/Enums/ActionSurfaceType.php
Normal file
60
app/Support/Ui/ActionSurface/Enums/ActionSurfaceType.php
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ui\ActionSurface\Enums;
|
||||||
|
|
||||||
|
enum ActionSurfaceType: string
|
||||||
|
{
|
||||||
|
case CrudListFirstResource = 'crud_list_first_resource';
|
||||||
|
case ReadOnlyRegistryReport = 'read_only_registry_report';
|
||||||
|
case QueueReview = 'queue_review';
|
||||||
|
case HistoryAudit = 'history_audit';
|
||||||
|
case ConfigLite = 'config_lite';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, ActionSurfaceInspectAffordance>
|
||||||
|
*/
|
||||||
|
public function allowedInspectAffordances(): array
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::CrudListFirstResource,
|
||||||
|
self::ReadOnlyRegistryReport,
|
||||||
|
self::ConfigLite => [
|
||||||
|
ActionSurfaceInspectAffordance::ClickableRow,
|
||||||
|
ActionSurfaceInspectAffordance::PrimaryLinkColumn,
|
||||||
|
],
|
||||||
|
self::QueueReview,
|
||||||
|
self::HistoryAudit => [
|
||||||
|
ActionSurfaceInspectAffordance::ViewAction,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function allowsInspectAffordance(ActionSurfaceInspectAffordance $affordance): bool
|
||||||
|
{
|
||||||
|
return in_array($affordance, $this->allowedInspectAffordances(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function expectsExplicitInspect(): bool
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::QueueReview,
|
||||||
|
self::HistoryAudit => true,
|
||||||
|
self::CrudListFirstResource,
|
||||||
|
self::ReadOnlyRegistryReport,
|
||||||
|
self::ConfigLite => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function supportsPrimaryLinkColumnException(): bool
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::CrudListFirstResource,
|
||||||
|
self::ReadOnlyRegistryReport,
|
||||||
|
self::ConfigLite => true,
|
||||||
|
self::QueueReview,
|
||||||
|
self::HistoryAudit => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,7 +3,7 @@ # Filament Actions UX Standard
|
|||||||
> Canonical rules for row actions, bulk actions, header actions, and inspect affordances on all Filament table surfaces.
|
> Canonical rules for row actions, bulk actions, header actions, and inspect affordances on all Filament table surfaces.
|
||||||
> This standard consolidates the Action Surface Contract from the constitution and `docs/ui/action-surface-contract.md`.
|
> This standard consolidates the Action Surface Contract from the constitution and `docs/ui/action-surface-contract.md`.
|
||||||
|
|
||||||
**Last reviewed**: 2026-03-09
|
**Last reviewed**: 2026-03-30
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -31,19 +31,36 @@ ## Inspect Affordance (Required)
|
|||||||
|
|
||||||
Every list-style surface must provide a way to open a record.
|
Every list-style surface must provide a way to open a record.
|
||||||
|
|
||||||
### Preferred: clickable rows
|
### Default: clickable rows for list-first and registry surfaces
|
||||||
|
|
||||||
```php
|
```php
|
||||||
$table->recordUrl(fn ($record) => /* route to view/edit */)
|
$table->recordUrl(fn ($record) => /* route to view/edit */)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Alternative: primary link column or View action
|
Use this default for:
|
||||||
|
|
||||||
Use only when clickable rows are impractical.
|
- CRUD / List-first resources
|
||||||
|
- Read-only registries / reports
|
||||||
|
- Reporting and evidence registers such as Review Register and Evidence Overview
|
||||||
|
|
||||||
|
### Explicit inspect for queue and audit surfaces
|
||||||
|
|
||||||
|
Use an `Inspect` row action or equivalent same-page selected detail when chronology or queue context must remain visible.
|
||||||
|
|
||||||
|
- Audit / history pages use explicit inspect
|
||||||
|
- Queue / review pages use explicit inspect
|
||||||
|
- These surfaces should not also expose row click as a competing open path
|
||||||
|
|
||||||
|
### Alternative: primary link column
|
||||||
|
|
||||||
|
Use only when clickable rows are impractical on a clickable-row surface.
|
||||||
|
|
||||||
|
- `PrimaryLinkColumn` requires a non-empty declaration reason
|
||||||
|
- It is not a fallback for queue / review or audit surfaces
|
||||||
|
|
||||||
### Rule: no lone "View" button
|
### Rule: no lone "View" button
|
||||||
|
|
||||||
If "View" is the only row action, prefer clickable rows and set `actions([])` to avoid an unnecessary Actions column.
|
If an open action is the only row action on a clickable-row surface, prefer row click and set `actions([])` to avoid an unnecessary Actions column.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -52,6 +69,7 @@ ## Row Action Limits
|
|||||||
- **Max 2 visible** row actions (typically View/Edit or Edit/Delete)
|
- **Max 2 visible** row actions (typically View/Edit or Edit/Delete)
|
||||||
- Everything else goes into an `ActionGroup` labeled "More"
|
- Everything else goes into an `ActionGroup` labeled "More"
|
||||||
- Destructive actions must never be the primary visible action
|
- Destructive actions must never be the primary visible action
|
||||||
|
- On clickable-row surfaces, prefer zero inline row actions unless a single justified shortcut materially improves the workflow
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -60,6 +78,7 @@ ## Bulk Actions
|
|||||||
- Bulk actions must be grouped via `BulkActionGroup`
|
- Bulk actions must be grouped via `BulkActionGroup`
|
||||||
- Destructive bulk actions require confirmation
|
- Destructive bulk actions require confirmation
|
||||||
- Typed confirmation may be required for large/bulk changes
|
- Typed confirmation may be required for large/bulk changes
|
||||||
|
- Do not leave an empty `BulkActionGroup` placeholder visible once filters, record state, or RBAC remove every effective action
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -80,18 +99,29 @@ ### Standard action labels
|
|||||||
|
|
||||||
| Action | Label | Icon guidance |
|
| Action | Label | Icon guidance |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| View record | "View" or clickable row | heroicon-o-eye |
|
| Inspect record | "Inspect" | heroicon-o-eye |
|
||||||
| Edit record | "Edit" | heroicon-o-pencil-square |
|
| Edit record | "Edit" | heroicon-o-pencil-square |
|
||||||
| Delete record | "Delete" | heroicon-o-trash |
|
| Delete record | "Delete" | heroicon-o-trash |
|
||||||
| Archive record | "Archive" | heroicon-o-archive-box |
|
| Archive record | "Archive" | heroicon-o-archive-box |
|
||||||
| Restore record | "Restore" | heroicon-o-arrow-uturn-left |
|
| Restore record | "Restore" | heroicon-o-arrow-uturn-left |
|
||||||
| Force delete | "Force Delete" | heroicon-o-trash |
|
| Force delete | "Force Delete" | heroicon-o-trash |
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Clickable-row surfaces normally do not render a `View` or `Inspect` row action at all.
|
||||||
|
- Reporting/evidence registers keep row click primary and should not add a duplicate `View review`-style action.
|
||||||
|
|
||||||
### Action ordering in "More" group
|
### Action ordering in "More" group
|
||||||
|
|
||||||
1. Non-destructive operations first
|
1. Navigation or inspect helpers first
|
||||||
2. Destructive operations last
|
2. Non-destructive workflow or lifecycle actions next
|
||||||
3. Separated by divider if Filament supports it
|
3. Destructive actions last
|
||||||
|
4. Do not render an empty `ActionGroup` placeholder
|
||||||
|
|
||||||
|
Representative expectations:
|
||||||
|
|
||||||
|
- `Policies`: `Export`, then `Sync`, then destructive ignore/delete actions
|
||||||
|
- `Backup schedules`: `Run now` / `Retry` before archive or force delete
|
||||||
|
- `Workspaces`: row click stays primary and the secondary `Edit` shortcut lives under `More`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -6,20 +6,72 @@ ## Inspect affordance (required)
|
|||||||
|
|
||||||
Any list-style surface that exposes records must provide an **inspect affordance** so an admin can open a record.
|
Any list-style surface that exposes records must provide an **inspect affordance** so an admin can open a record.
|
||||||
|
|
||||||
Accepted implementations:
|
### Surface-type decision tree
|
||||||
|
|
||||||
|
The inspect model is driven by the declaration `surfaceType`:
|
||||||
|
|
||||||
|
- **CRUD / List-first Resource**
|
||||||
|
Use one-click open by default, normally `recordUrl()`. `PrimaryLinkColumn` is allowed only as an explicit exception with a concrete reason.
|
||||||
|
- **Read-only Registry / Report**
|
||||||
|
Use one-click open by default, normally `recordUrl()`. This includes scan-first reporting surfaces such as Monitoring Operations, Review Register, Evidence Overview, and read-only registry resources.
|
||||||
|
- **Queue / Review**
|
||||||
|
Use explicit inspect (`Inspect` row action or equivalent same-page selected detail). Do not make the full row clickable.
|
||||||
|
- **History / Audit**
|
||||||
|
Use explicit inspect (`Inspect` row action or equivalent same-page selected detail). Do not make the full row clickable.
|
||||||
|
- **Config-lite**
|
||||||
|
Edit-as-inspect is allowed, but it still uses one obvious open path and must not add a competing `View` action.
|
||||||
|
|
||||||
|
### Accepted implementations
|
||||||
|
|
||||||
- **Clickable rows** (preferred): set `recordUrl()` for the table.
|
- **Clickable rows** (preferred): set `recordUrl()` for the table.
|
||||||
- **View action**: a `ViewAction` in the row actions.
|
- **Inspect action**: a row action used only on queue / review or history / audit surfaces where context must stay on the same page.
|
||||||
- **Primary link column**: a column that is clearly the primary affordance to open the record.
|
- **Primary link column**: a column that is clearly the primary affordance to open the record, with an explicit `PrimaryLinkColumn` reason in the declaration.
|
||||||
|
|
||||||
### Rule: no lone “View” button
|
### Rule: no lone “View” button
|
||||||
|
|
||||||
Avoid rendering a table that only has a single `View` row action. This creates visual noise and adds an unnecessary Actions column.
|
Avoid rendering a table that only has a single inspect-style row action on a clickable-row surface. This creates visual noise and adds an unnecessary Actions column.
|
||||||
|
|
||||||
Preferred approach:
|
Preferred approach:
|
||||||
|
|
||||||
- Make the row clickable via `recordUrl()` and set `actions([])` so no Actions column is rendered.
|
- Make the row clickable via `recordUrl()` and set `actions([])` so no Actions column is rendered.
|
||||||
|
|
||||||
|
### PrimaryLinkColumn exception rule
|
||||||
|
|
||||||
|
Use `PrimaryLinkColumn` only when full-row click is the wrong interaction model for that specific surface.
|
||||||
|
|
||||||
|
- The declaration must use a clickable surface type (`CrudListFirstResource`, `ReadOnlyRegistryReport`, or `ConfigLite`).
|
||||||
|
- The declaration must include a non-empty `primaryLinkColumnReason`.
|
||||||
|
- Queue / review and history / audit surfaces may not use `PrimaryLinkColumn` as a shortcut around explicit inspect.
|
||||||
|
|
||||||
|
### Reporting / evidence register rule
|
||||||
|
|
||||||
|
Review and evidence registers are governed as **ReadOnlyRegistryReport** surfaces.
|
||||||
|
|
||||||
|
- `ReviewRegister` and `EvidenceOverview` keep clickable-row inspection as the primary open path.
|
||||||
|
- Do not add a duplicate `View review` or equivalent open action beside the row click.
|
||||||
|
- Safe non-inspect shortcuts may remain when they are clearly secondary.
|
||||||
|
|
||||||
|
## More-menu ordering
|
||||||
|
|
||||||
|
Governed `ActionGroup` and `BulkActionGroup` menus use one stable order:
|
||||||
|
|
||||||
|
- Navigation or inspect helpers first
|
||||||
|
- Non-destructive workflow or lifecycle actions next
|
||||||
|
- Destructive actions last
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- `Policies`: export before sync, sync before ignore/delete
|
||||||
|
- `Backup schedules`: run/retry before archive or force delete
|
||||||
|
- `Tenants`: related onboarding and safe navigation shortcuts before sync or verification, with archive/force delete trailing
|
||||||
|
|
||||||
|
## Placeholder groups are forbidden
|
||||||
|
|
||||||
|
`ActionGroup` and `BulkActionGroup` exist to hold real secondary actions, not to reserve layout space.
|
||||||
|
|
||||||
|
- Do not render an empty `More` menu after visibility, record-state, or RBAC filtering removes every effective action.
|
||||||
|
- On clickable-row surfaces with only one safe shortcut, that shortcut may still live under `More` when it preserves a cleaner scan-first list.
|
||||||
|
|
||||||
## RBAC / safety
|
## RBAC / safety
|
||||||
|
|
||||||
- If the current user cannot inspect a record, `recordUrl()` must return `null` for that record.
|
- If the current user cannot inspect a record, `recordUrl()` must return `null` for that record.
|
||||||
|
|||||||
@ -24,7 +24,7 @@
|
|||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<x-filament::section>
|
<x-filament::section>
|
||||||
<x-slot name="heading">
|
<x-slot name="heading">
|
||||||
Run #{{ (int) $run->getKey() }}
|
Operation #{{ (int) $run->getKey() }}
|
||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
<x-slot name="description">
|
<x-slot name="description">
|
||||||
@ -88,11 +88,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Runbooks</dt>
|
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Navigation</dt>
|
||||||
<dd class="mt-1 text-sm">
|
<dd class="mt-1 text-sm">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<x-filament::link href="{{ \App\Support\System\SystemOperationRunLinks::index() }}">
|
||||||
|
Show all operations
|
||||||
|
</x-filament::link>
|
||||||
|
|
||||||
<x-filament::link href="{{ \App\Filament\System\Pages\Ops\Runbooks::getUrl(panel: 'system') }}">
|
<x-filament::link href="{{ \App\Filament\System\Pages\Ops\Runbooks::getUrl(panel: 'system') }}">
|
||||||
Go to runbooks
|
Go to runbooks
|
||||||
</x-filament::link>
|
</x-filament::link>
|
||||||
|
</div>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|||||||
35
specs/169-action-surface-v11/checklists/requirements.md
Normal file
35
specs/169-action-surface-v11/checklists/requirements.md
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# Specification Quality Checklist: Action Surface Contract v1.1: Inspect Decision Rules, Menu Ordering, and Behavior Guard Coverage
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-03-30
|
||||||
|
**Feature**: [spec.md](../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
|
||||||
|
|
||||||
|
- Validated against the active Spec Kit template, constitution retrofit priorities, and current repository state.
|
||||||
|
- System-panel enrollment and relation-manager rollout work were treated as completed current-state evidence, so this spec stays bounded to the remaining foundation-and-enforcement gap.
|
||||||
@ -0,0 +1,347 @@
|
|||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
title: Action Surface Governance Internal Contract
|
||||||
|
version: 0.1.0
|
||||||
|
summary: Internal logical contract for declaration, discovery, and validation behavior in Spec 169
|
||||||
|
description: |
|
||||||
|
This contract is an internal planning artifact for Spec 169. It documents the
|
||||||
|
declaration shape, discovery scope, and validation behavior required to keep
|
||||||
|
the Action Surface Contract behavior-aware. It does not add a public HTTP API.
|
||||||
|
servers:
|
||||||
|
- url: /internal
|
||||||
|
x-governed-surface-families:
|
||||||
|
- family: clickable_row_reference
|
||||||
|
classes:
|
||||||
|
- app/Filament/Pages/Monitoring/Operations.php
|
||||||
|
- app/Filament/Resources/OperationRunResource.php
|
||||||
|
guardTests:
|
||||||
|
- tests/Feature/Guards/ActionSurfaceContractTest.php
|
||||||
|
- family: explicit_inspect_history_reference
|
||||||
|
classes:
|
||||||
|
- app/Filament/Pages/Monitoring/AuditLog.php
|
||||||
|
- app/Filament/System/Pages/Security/AccessLogs.php
|
||||||
|
guardTests:
|
||||||
|
- tests/Feature/Guards/ActionSurfaceContractTest.php
|
||||||
|
- family: explicit_inspect_queue_reference
|
||||||
|
classes:
|
||||||
|
- app/Filament/Pages/Monitoring/FindingExceptionsQueue.php
|
||||||
|
guardTests:
|
||||||
|
- tests/Feature/Guards/ActionSurfaceContractTest.php
|
||||||
|
- family: reporting_registry_reference
|
||||||
|
classes:
|
||||||
|
- app/Filament/Pages/Monitoring/EvidenceOverview.php
|
||||||
|
- app/Filament/Pages/Reviews/ReviewRegister.php
|
||||||
|
guardTests:
|
||||||
|
- tests/Feature/Guards/ActionSurfaceContractTest.php
|
||||||
|
- family: destructive_last_reference
|
||||||
|
classes:
|
||||||
|
- app/Filament/Resources/BackupScheduleResource.php
|
||||||
|
- app/Filament/Resources/TenantResource.php
|
||||||
|
guardTests:
|
||||||
|
- tests/Feature/Guards/ActionSurfaceContractTest.php
|
||||||
|
- tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php
|
||||||
|
- family: system_discovery_reference
|
||||||
|
classes:
|
||||||
|
- app/Filament/System/Pages/Ops/Runs.php
|
||||||
|
- app/Filament/System/Pages/Ops/Failures.php
|
||||||
|
- app/Filament/System/Pages/Ops/Stuck.php
|
||||||
|
- app/Filament/System/Pages/Directory/Tenants.php
|
||||||
|
- app/Filament/System/Pages/Directory/Workspaces.php
|
||||||
|
- app/Filament/System/Pages/Security/AccessLogs.php
|
||||||
|
guardTests:
|
||||||
|
- tests/Feature/Guards/ActionSurfaceContractTest.php
|
||||||
|
paths:
|
||||||
|
/action-surfaces/discovered:
|
||||||
|
get:
|
||||||
|
summary: Discover the repository-wide action-surface validation scope
|
||||||
|
operationId: discoverActionSurfaces
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Current discovery snapshot for validator coverage
|
||||||
|
content:
|
||||||
|
application/vnd.tenantatlas.action-surface-discovery+json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/DiscoverySnapshot'
|
||||||
|
/action-surfaces/components/{className}:
|
||||||
|
get:
|
||||||
|
summary: Resolve the declaration contract for one discovered component
|
||||||
|
operationId: resolveActionSurfaceDeclaration
|
||||||
|
parameters:
|
||||||
|
- name: className
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Declaration contract for the requested component
|
||||||
|
content:
|
||||||
|
application/vnd.tenantatlas.action-surface-declaration+json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ActionSurfaceDeclarationV11'
|
||||||
|
'404':
|
||||||
|
description: Component is not in the primary discovery scope
|
||||||
|
/action-surfaces/validate:
|
||||||
|
post:
|
||||||
|
summary: Validate discovered declarations against behavior-aware contract rules
|
||||||
|
operationId: validateActionSurfaces
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/vnd.tenantatlas.action-surface-validation-request+json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ValidationRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Validation completed
|
||||||
|
content:
|
||||||
|
application/vnd.tenantatlas.action-surface-validation-result+json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ValidationResult'
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
ActionSurfaceComponentType:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- resource
|
||||||
|
- page
|
||||||
|
- relation_manager
|
||||||
|
ActionSurfaceProfile:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- crud_list_and_edit
|
||||||
|
- crud_list_and_view
|
||||||
|
- list_only_read_only
|
||||||
|
- run_log
|
||||||
|
- relation_manager
|
||||||
|
ActionSurfaceType:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- crud_list_first_resource
|
||||||
|
- read_only_registry_report
|
||||||
|
- queue_review
|
||||||
|
- history_audit
|
||||||
|
- config_lite
|
||||||
|
ActionSurfaceInspectAffordance:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- clickable_row
|
||||||
|
- view_action
|
||||||
|
- primary_link_column
|
||||||
|
ActionSurfaceSlot:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- list_header
|
||||||
|
- inspect_affordance
|
||||||
|
- list_row_more_menu
|
||||||
|
- list_bulk_more_group
|
||||||
|
- list_empty_state
|
||||||
|
- detail_header
|
||||||
|
ActionSurfaceDefaults:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- moreGroupLabel
|
||||||
|
- exportIsDefaultBulkActionForReadOnly
|
||||||
|
properties:
|
||||||
|
moreGroupLabel:
|
||||||
|
type: string
|
||||||
|
const: More
|
||||||
|
exportIsDefaultBulkActionForReadOnly:
|
||||||
|
type: boolean
|
||||||
|
SlotRequirement:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- state
|
||||||
|
properties:
|
||||||
|
state:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- satisfied
|
||||||
|
- exempt
|
||||||
|
details:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
requiresTypedConfirmation:
|
||||||
|
type: boolean
|
||||||
|
SlotExemption:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- slot
|
||||||
|
- reason
|
||||||
|
properties:
|
||||||
|
slot:
|
||||||
|
$ref: '#/components/schemas/ActionSurfaceSlot'
|
||||||
|
reason:
|
||||||
|
type: string
|
||||||
|
trackingRef:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
ActionSurfaceDeclarationV11:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- version
|
||||||
|
- componentType
|
||||||
|
- profile
|
||||||
|
- surfaceType
|
||||||
|
- defaults
|
||||||
|
- slots
|
||||||
|
properties:
|
||||||
|
version:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
componentType:
|
||||||
|
$ref: '#/components/schemas/ActionSurfaceComponentType'
|
||||||
|
profile:
|
||||||
|
$ref: '#/components/schemas/ActionSurfaceProfile'
|
||||||
|
surfaceType:
|
||||||
|
$ref: '#/components/schemas/ActionSurfaceType'
|
||||||
|
defaults:
|
||||||
|
$ref: '#/components/schemas/ActionSurfaceDefaults'
|
||||||
|
slots:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
$ref: '#/components/schemas/SlotRequirement'
|
||||||
|
exemptions:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/SlotExemption'
|
||||||
|
listRowPrimaryActionLimit:
|
||||||
|
type:
|
||||||
|
- integer
|
||||||
|
- 'null'
|
||||||
|
minimum: 0
|
||||||
|
primaryLinkColumnReason:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
DiscoveredComponent:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- className
|
||||||
|
- componentType
|
||||||
|
properties:
|
||||||
|
className:
|
||||||
|
type: string
|
||||||
|
componentType:
|
||||||
|
$ref: '#/components/schemas/ActionSurfaceComponentType'
|
||||||
|
discoveryFamily:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- resource
|
||||||
|
- page
|
||||||
|
- relation_manager
|
||||||
|
- system_table_page
|
||||||
|
DiscoverySnapshot:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- components
|
||||||
|
- excludedFamilies
|
||||||
|
properties:
|
||||||
|
components:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/DiscoveredComponent'
|
||||||
|
excludedFamilies:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
example:
|
||||||
|
- widgets
|
||||||
|
- auth_pages
|
||||||
|
- dashboards
|
||||||
|
- chooser_pages
|
||||||
|
- onboarding_wizards
|
||||||
|
- deferred_system_pages
|
||||||
|
InspectDecisionRule:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- surfaceType
|
||||||
|
- allowedAffordances
|
||||||
|
- redundantViewForbidden
|
||||||
|
properties:
|
||||||
|
surfaceType:
|
||||||
|
$ref: '#/components/schemas/ActionSurfaceType'
|
||||||
|
allowedAffordances:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ActionSurfaceInspectAffordance'
|
||||||
|
redundantViewForbidden:
|
||||||
|
type: boolean
|
||||||
|
primaryLinkReasonRequired:
|
||||||
|
type: boolean
|
||||||
|
OrderingRule:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- moreGroupLabel
|
||||||
|
- inspectionHelpersFirst
|
||||||
|
- workflowAfterNavigation
|
||||||
|
- destructiveLast
|
||||||
|
- emptyGroupsForbidden
|
||||||
|
properties:
|
||||||
|
moreGroupLabel:
|
||||||
|
type: string
|
||||||
|
const: More
|
||||||
|
inspectionHelpersFirst:
|
||||||
|
type: boolean
|
||||||
|
workflowAfterNavigation:
|
||||||
|
type: boolean
|
||||||
|
destructiveLast:
|
||||||
|
type: boolean
|
||||||
|
emptyGroupsForbidden:
|
||||||
|
type: boolean
|
||||||
|
ValidationRequest:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- enforceSystemPanelDiscovery
|
||||||
|
- enforceBehaviorRules
|
||||||
|
properties:
|
||||||
|
enforceSystemPanelDiscovery:
|
||||||
|
type: boolean
|
||||||
|
const: true
|
||||||
|
enforceBehaviorRules:
|
||||||
|
type: boolean
|
||||||
|
const: true
|
||||||
|
ValidationIssue:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- className
|
||||||
|
- message
|
||||||
|
properties:
|
||||||
|
className:
|
||||||
|
type: string
|
||||||
|
slot:
|
||||||
|
anyOf:
|
||||||
|
- $ref: '#/components/schemas/ActionSurfaceSlot'
|
||||||
|
- type: 'null'
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
hint:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
ValidationResult:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- componentCount
|
||||||
|
- issues
|
||||||
|
properties:
|
||||||
|
componentCount:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
issues:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ValidationIssue'
|
||||||
195
specs/169-action-surface-v11/data-model.md
Normal file
195
specs/169-action-surface-v11/data-model.md
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
# Phase 1 Data Model: Action Surface Contract v1.1
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This feature does not add a database table, persisted artifact, or cache. It extends the existing runtime action-surface contract with one new declaration-level enum and codifies the derived rules that govern discovery, inspect behavior, and overflow ordering for the enrolled reference surfaces.
|
||||||
|
|
||||||
|
## Existing Runtime Source Objects
|
||||||
|
|
||||||
|
### ActionSurfaceDeclaration v1.0
|
||||||
|
|
||||||
|
**Purpose**: Existing declaration object used by Resources, Pages, and RelationManagers to describe action-surface requirements.
|
||||||
|
|
||||||
|
**Current fields**:
|
||||||
|
- `version`
|
||||||
|
- `componentType`
|
||||||
|
- `profile`
|
||||||
|
- `defaults`
|
||||||
|
- `slots`
|
||||||
|
- `exemptions`
|
||||||
|
- `metadata`
|
||||||
|
|
||||||
|
**Current behavior**:
|
||||||
|
- Declares slot satisfaction or exemption.
|
||||||
|
- Stores ad hoc metadata such as list row primary action limit.
|
||||||
|
- Does not currently encode constitution surface type explicitly.
|
||||||
|
|
||||||
|
### ActionSurfaceProfile
|
||||||
|
|
||||||
|
**Purpose**: Existing slot-requirement family used by `ActionSurfaceProfileDefinition`.
|
||||||
|
|
||||||
|
**Current values**:
|
||||||
|
- `CrudListAndEdit`
|
||||||
|
- `CrudListAndView`
|
||||||
|
- `ListOnlyReadOnly`
|
||||||
|
- `RunLog`
|
||||||
|
- `RelationManager`
|
||||||
|
|
||||||
|
**Validation role**:
|
||||||
|
- Drives which slots are required.
|
||||||
|
- Does not safely express whether a surface must be clickable-row, explicit-inspect, or edit-as-inspect.
|
||||||
|
|
||||||
|
### ActionSurfaceDiscovery and ActionSurfaceDiscoveredComponent
|
||||||
|
|
||||||
|
**Purpose**: Discover the repository’s in-scope declaration-backed components and pass them to the validator.
|
||||||
|
|
||||||
|
**Current fields on discovered component**:
|
||||||
|
- `className`
|
||||||
|
- `componentType`
|
||||||
|
- `panelScopes`
|
||||||
|
|
||||||
|
**Current limitation**:
|
||||||
|
- The main discovery pass covers declaration-backed tenant/admin surfaces but still excludes the enrolled system-panel list pages and cannot yet enforce the constitution behavior split precisely enough on the reference surfaces.
|
||||||
|
|
||||||
|
### ActionSurfaceValidationIssue and ActionSurfaceValidationResult
|
||||||
|
|
||||||
|
**Purpose**: Existing validator output used by the guard suite.
|
||||||
|
|
||||||
|
**Current role**:
|
||||||
|
- Reports missing declarations, missing required slots, invalid inspect-affordance tokens, missing exemption reasons, and invalid read-only export defaults.
|
||||||
|
|
||||||
|
## New Runtime Entities
|
||||||
|
|
||||||
|
### ActionSurfaceType
|
||||||
|
|
||||||
|
**Purpose**: First-class constitution-aligned behavioral classification used to determine the allowed primary inspect model for a surface.
|
||||||
|
|
||||||
|
#### Values
|
||||||
|
|
||||||
|
| Value | Description | Allowed primary inspect model |
|
||||||
|
|-------|-------------|-------------------------------|
|
||||||
|
| `CrudListFirstResource` | Standard list-first CRUD resource where open and mutate decisions happen after entering the record | Clickable row by default; `PrimaryLinkColumn` only with explicit reason |
|
||||||
|
| `ReadOnlyRegistryReport` | Scan-first registry or report surface with read-mostly or immutable records | Clickable row by default; `PrimaryLinkColumn` only with explicit reason |
|
||||||
|
| `QueueReview` | Queue where the operator reviews an item in context and continues the queue | Explicit inspect / same-page selected detail; row click forbidden by default |
|
||||||
|
| `HistoryAudit` | Immutable history or audit surface where chronology and context must be preserved | Explicit inspect / same-page selected detail; row click forbidden by default |
|
||||||
|
| `ConfigLite` | Low-cardinality configuration where edit is the primary inspect surface | Edit-as-inspect allowed by default; no parallel View surface |
|
||||||
|
|
||||||
|
#### Validation rules
|
||||||
|
|
||||||
|
- Every declaration-backed component enrolled in the v1.1 reference pack must declare one `ActionSurfaceType`.
|
||||||
|
- `ActionSurfaceType` determines inspect-model compatibility; `ActionSurfaceProfile` continues to determine required slots.
|
||||||
|
- `PrimaryLinkColumn` is valid only when a concrete reason explains why row click is not the correct primary inspect model.
|
||||||
|
|
||||||
|
### ActionSurfaceDeclaration v1.1
|
||||||
|
|
||||||
|
**Purpose**: Extended declaration contract that combines slot requirements with explicit constitution behavior.
|
||||||
|
|
||||||
|
#### Fields
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `version` | int | yes | Contract version |
|
||||||
|
| `componentType` | enum | yes | Resource, Page, or RelationManager |
|
||||||
|
| `profile` | `ActionSurfaceProfile` | yes | Existing slot-requirement family |
|
||||||
|
| `surfaceType` | `ActionSurfaceType` | yes | New constitution-aligned behavior family |
|
||||||
|
| `defaults` | `ActionSurfaceDefaults` | yes | Shared defaults such as `moreGroupLabel` |
|
||||||
|
| `slots` | map<ActionSurfaceSlot, ActionSurfaceSlotRequirement> | yes | Slot requirements and satisfied declarations |
|
||||||
|
| `exemptions` | map<ActionSurfaceSlot, ActionSurfaceExemption> | no | Explicit exemptions with reasons |
|
||||||
|
| `metadata` | map<string, mixed> | no | Existing narrow metadata storage |
|
||||||
|
| `listRowPrimaryActionLimit` | int nullable | no | Existing row-action budget metadata |
|
||||||
|
| `primaryLinkColumnReason` | string nullable | no | Required when the inspect affordance is `PrimaryLinkColumn` |
|
||||||
|
|
||||||
|
#### Relationships
|
||||||
|
|
||||||
|
- One declaration belongs to exactly one component class.
|
||||||
|
- One declaration has exactly one `ActionSurfaceProfile` and one `ActionSurfaceType`.
|
||||||
|
- One declaration contains many slot requirements and optional slot exemptions.
|
||||||
|
|
||||||
|
#### Validation rules
|
||||||
|
|
||||||
|
- `surfaceType` is required for all components inside the v1.1 primary discovery scope.
|
||||||
|
- `surfaceType` and `InspectAffordance` must be compatible.
|
||||||
|
- `defaults.moreGroupLabel` must remain `More`.
|
||||||
|
- Empty or reasonless exemptions remain invalid.
|
||||||
|
|
||||||
|
### InspectDecisionRule
|
||||||
|
|
||||||
|
**Purpose**: Derived rule family that maps `surfaceType` to allowed inspect behavior.
|
||||||
|
|
||||||
|
#### Fields
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `surfaceType` | `ActionSurfaceType` | yes | Surface family being governed |
|
||||||
|
| `allowedAffordances` | list<`ActionSurfaceInspectAffordance`> | yes | Allowed inspect affordance values |
|
||||||
|
| `forbiddenRedundantView` | bool | yes | Whether a lone row `View` action is forbidden |
|
||||||
|
| `requiresPrimaryLinkReason` | bool | yes | Whether `PrimaryLinkColumn` must include a concrete reason |
|
||||||
|
|
||||||
|
#### Validation rules
|
||||||
|
|
||||||
|
- CRUD and registry surfaces must expose one obvious open path and may not render a redundant lone `View` row action.
|
||||||
|
- Queue and audit surfaces must preserve context through explicit inspect.
|
||||||
|
- Config-lite surfaces may open edit as inspect but may not add a competing View surface.
|
||||||
|
|
||||||
|
### ActionOrderingRule
|
||||||
|
|
||||||
|
**Purpose**: Derived rule family that governs `ActionGroup` and `BulkActionGroup` content on governed surfaces.
|
||||||
|
|
||||||
|
#### Fields
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `groupLabel` | string | yes | Must remain `More` for governed overflow groups |
|
||||||
|
| `inspectionHelpersFirst` | bool | yes | Navigation and safe inspection helpers come first |
|
||||||
|
| `workflowAfterNavigation` | bool | yes | Non-destructive lifecycle and workflow actions follow safe helpers |
|
||||||
|
| `destructiveLast` | bool | yes | Destructive actions sort last |
|
||||||
|
| `emptyGroupForbidden` | bool | yes | Empty `ActionGroup` and `BulkActionGroup` placeholders are invalid |
|
||||||
|
|
||||||
|
#### Validation rules
|
||||||
|
|
||||||
|
- Overflow groups must not exist only as placeholders.
|
||||||
|
- Inspection and navigation helpers must lead the group when present.
|
||||||
|
- Non-destructive lifecycle and workflow actions must follow safe helpers and must not trail destructive actions.
|
||||||
|
- Destructive actions must sort last.
|
||||||
|
- Ordering checks are enforced through representative rendered guard tests rather than generic runtime reflection across every surface.
|
||||||
|
|
||||||
|
### PrimaryDiscoveryScope
|
||||||
|
|
||||||
|
**Purpose**: Defines which repository surfaces the primary validator must discover.
|
||||||
|
|
||||||
|
#### Included families
|
||||||
|
|
||||||
|
- Enrolled monitoring and reporting pages under `app/Filament/Pages/**`
|
||||||
|
- Enrolled representative CRUD and read-only registry resources under `app/Filament/Resources/**`
|
||||||
|
- Declared, table-backed pages under `app/Filament/System/Pages/**` for the six enrolled system list surfaces
|
||||||
|
|
||||||
|
#### Excluded families
|
||||||
|
|
||||||
|
- Widgets
|
||||||
|
- Auth pages
|
||||||
|
- Dashboards
|
||||||
|
- Choosers and onboarding wizards
|
||||||
|
- Deferred or non-table system tooling such as Runbooks
|
||||||
|
- Any class kept under explicit baseline exemption until a later spec enrolls it
|
||||||
|
|
||||||
|
#### Validation rules
|
||||||
|
|
||||||
|
- Discovery must not require new baseline exemptions for already enrolled system pages.
|
||||||
|
- Out-of-scope families must remain explicitly excluded rather than silently swept in.
|
||||||
|
|
||||||
|
## Representative Surface Mapping
|
||||||
|
|
||||||
|
| Surface | Profile | Surface type | Inspect affordance | Ordering anchor |
|
||||||
|
|---------|---------|--------------|--------------------|-----------------|
|
||||||
|
| `Monitoring/Operations` | `RunLog` | `ReadOnlyRegistryReport` | `ClickableRow` | No row actions; row click is the anchor |
|
||||||
|
| `Monitoring/AuditLog` | `RunLog` | `HistoryAudit` | Explicit inspect | No row click; context-preserving inspect is the anchor |
|
||||||
|
| `Monitoring/FindingExceptionsQueue` | `RunLog` | `QueueReview` | Explicit inspect | Selected-record workflow actions stay off the standard list row |
|
||||||
|
| `BaselineProfileResource` | `CrudListAndView` | `CrudListFirstResource` | `ClickableRow` | `More` contains safe actions first and archive last |
|
||||||
|
| `System/Ops/Runs` | `RunLog` | `ReadOnlyRegistryReport` | `ClickableRow` to canonical system run detail | Cross-panel registry coverage anchor that preserves the canonical `Operations / Run` noun |
|
||||||
|
| `System/Security/AccessLogs` | `RunLog` | `HistoryAudit` | Explicit inspect | System audit reference anchor |
|
||||||
|
|
||||||
|
## Migration Notes
|
||||||
|
|
||||||
|
- No schema migration is required.
|
||||||
|
- No new queue, notification, or asset behavior is introduced.
|
||||||
|
- The final validator state requires explicit `surfaceType` on every discovered declaration-backed surface.
|
||||||
257
specs/169-action-surface-v11/plan.md
Normal file
257
specs/169-action-surface-v11/plan.md
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
# Implementation Plan: Action Surface Contract v1.1
|
||||||
|
|
||||||
|
**Branch**: `169-action-surface-v11` | **Date**: 2026-03-30 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/169-action-surface-v11/spec.md`
|
||||||
|
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/169-action-surface-v11/spec.md`
|
||||||
|
|
||||||
|
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Strengthen the existing action-surface contract so rendered behavior, not declaration presence alone, becomes the governing truth. The implementation adds one first-class constitution-aligned `surface_type` enum to `ActionSurfaceDeclaration` while keeping `ActionSurfaceProfile` as the slot-requirement model, extends primary discovery to the enrolled system-panel table pages under `app/Filament/System/Pages`, codifies inspect-model and `More`-menu ordering rules in the validator and reference docs, and protects the contract with both fast validator tests and representative Livewire guard tests anchored on clickable-row, explicit-inspect, and system-panel reference surfaces.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4.15
|
||||||
|
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, existing `ActionSurfaceDeclaration`, `ActionSurfaceValidator`, `ActionSurfaceDiscovery`, `ActionSurfaceExemptions`, and Filament Tables / Actions APIs
|
||||||
|
**Storage**: PostgreSQL unchanged; no new persistence, cache store, queue payload, or durable artifact
|
||||||
|
**Testing**: Pest 4 feature tests and Livewire component tests, including validator stubs and rendered table guard coverage, executed through Laravel Sail
|
||||||
|
**Target Platform**: Laravel monolith web application in Sail locally and containerized Linux deployment in staging/production
|
||||||
|
**Project Type**: web application
|
||||||
|
**Performance Goals**: Keep repository-wide action-surface validation deterministic and CI-friendly, add no new runtime queries or cross-request state to operator surfaces, and keep behavior-aware render checks limited to representative guard surfaces rather than every declaration-backed class
|
||||||
|
**Constraints**: Derived-only governance slice, no new business routes or assets, no new capability or policy family, no new baseline exemptions for already enrolled surfaces, chooser/dashboard/widget/onboarding exemptions remain explicit, and all work must stay within Filament v5 / Livewire v4 conventions
|
||||||
|
**Scale/Scope**: The enrolled reference surfaces named by Spec 169 across monitoring pages, reporting/evidence registers, representative CRUD and read-only registry resources, and the six enrolled system-panel list pages; representative render coverage for clickable-row, explicit-inspect, ordered overflow behavior, and out-of-scope preservation
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing.*
|
||||||
|
|
||||||
|
| Principle | Pre-Research | Post-Design | Notes |
|
||||||
|
|-----------|--------------|-------------|-------|
|
||||||
|
| Read/write separation | PASS | PASS | This is a UI governance and guard-coverage slice only. No domain write path, operation start surface, or Graph call changes are introduced. |
|
||||||
|
| Workspace + tenant isolation / RBAC-UX | PASS | PASS | Discovery coverage broadens validation only; it does not alter route visibility, panel access, 404 vs 403 semantics, or capability enforcement on tenant or system surfaces. |
|
||||||
|
| Proportionality / no premature abstraction | PASS WITH JUSTIFIED ENUM | PASS WITH JUSTIFIED ENUM | One new first-class enum and one declaration field are justified because `ActionSurfaceProfile` cannot distinguish constitution-governed inspect models across CRUD, queue, audit, and registry surfaces. No second registry or UI meta-framework is introduced. |
|
||||||
|
| Persisted truth / behavioral state | PASS | PASS | No new table, artifact, cache, or persisted status family is introduced. The new `surface_type` enum is declaration-time behavior metadata only. |
|
||||||
|
| UI constitution / one inspect model / placeholder ban | PASS | PASS | This feature directly enforces `UI-SURF-001`, `UI-HARD-001`, `UI-EX-001`, and `UI-REVIEW-001` by failing redundant inspect patterns, empty groups, and mismatched surface behavior. |
|
||||||
|
| Filament-native UI | PASS | PASS | The implementation continues to govern native Filament `recordUrl()`, row actions, `ActionGroup`, and `BulkActionGroup` rather than inventing replacement UI. |
|
||||||
|
| Filament v5 / Livewire v4 compliance | PASS | PASS | The plan keeps all work inside the current Filament v5 + Livewire v4 stack and adds only Livewire-compatible component tests. |
|
||||||
|
| Provider registration location | PASS | PASS | No panel provider change is required; Laravel 11+ provider registration remains in `bootstrap/providers.php`. |
|
||||||
|
| Global search hard rule | PASS | PASS | No globally searchable resource behavior changes are introduced in this slice. |
|
||||||
|
| Destructive action safety | PASS | PASS | Existing destructive actions remain executed through Filament actions with confirmation and authorization; this feature only validates placement and ordering. |
|
||||||
|
| Asset strategy | PASS | PASS | No new panel or shared assets are added, so there is no `filament:assets` deployment impact. |
|
||||||
|
| Testing truth (TEST-TRUTH-001) | PASS | PASS | The plan uses fast validator stubs for semantic rules and representative rendered tests for business-visible behavior instead of expanding broad presentation-only test matrices. |
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
## Phase 0 Research
|
||||||
|
|
||||||
|
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/169-action-surface-v11/research.md`.
|
||||||
|
|
||||||
|
Key decisions:
|
||||||
|
|
||||||
|
- Add a first-class `ActionSurfaceType` enum to `ActionSurfaceDeclaration` while keeping `ActionSurfaceProfile` as the slot-requirement model.
|
||||||
|
- Keep inspect-model compatibility rules close to the existing contract stack by implementing them in the validator and enum helpers rather than a second registry or framework layer.
|
||||||
|
- Extend discovery narrowly to declared system-panel table pages under `app/Filament/System/Pages`, using table + declaration opt-in so auth, dashboards, widgets, runbooks, and other deferred surfaces remain out of scope.
|
||||||
|
- Keep current panel-scope metadata unchanged for this slice because no active consumer relies on a system-panel scope enum, and the feature’s requirement is validation coverage rather than a new panel taxonomy.
|
||||||
|
- Split guard coverage between fast validator tests and representative Livewire render tests so declaration-level drift and rendered-behavior drift are both blocked.
|
||||||
|
|
||||||
|
## Phase 1 Design
|
||||||
|
|
||||||
|
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/169-action-surface-v11/`:
|
||||||
|
|
||||||
|
- `research.md`: phase-0 decisions and rejected alternatives
|
||||||
|
- `data-model.md`: declaration v1.1 contract, surface-type enum, decision rules, and discovery-scope model
|
||||||
|
- `contracts/action-surface-governance.logical.openapi.yaml`: internal logical contract for declaration, discovery, and validation behavior
|
||||||
|
- `quickstart.md`: focused implementation and verification workflow
|
||||||
|
|
||||||
|
Design decisions:
|
||||||
|
|
||||||
|
- `ActionSurfaceType` is explicit declaration data, not derived metadata.
|
||||||
|
- `ActionSurfaceProfile` remains in place to drive required-slot validation.
|
||||||
|
- `PrimaryLinkColumn` remains an exception path and requires an explicit reason instead of a new exception object model.
|
||||||
|
- System-panel discovery uses filesystem scope plus declaration opt-in instead of a hardcoded class allowlist.
|
||||||
|
- Behavior-aware ordering checks stay in representative tests rather than trying to introspect every Filament action tree generically.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/169-action-surface-v11/
|
||||||
|
├── spec.md
|
||||||
|
├── plan.md
|
||||||
|
├── research.md
|
||||||
|
├── data-model.md
|
||||||
|
├── quickstart.md
|
||||||
|
├── contracts/
|
||||||
|
│ └── action-surface-governance.logical.openapi.yaml
|
||||||
|
├── checklists/
|
||||||
|
│ └── requirements.md
|
||||||
|
└── tasks.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/
|
||||||
|
├── Filament/
|
||||||
|
│ ├── Pages/
|
||||||
|
│ │ └── Monitoring/
|
||||||
|
│ │ ├── AuditLog.php
|
||||||
|
│ │ ├── FindingExceptionsQueue.php
|
||||||
|
│ │ └── Operations.php
|
||||||
|
│ ├── Resources/
|
||||||
|
│ │ ├── BackupScheduleResource.php
|
||||||
|
│ │ ├── BaselineProfileResource.php
|
||||||
|
│ │ ├── OperationRunResource.php
|
||||||
|
│ │ ├── PolicyResource.php
|
||||||
|
│ │ ├── TenantResource.php
|
||||||
|
│ │ └── Workspaces/
|
||||||
|
│ │ └── WorkspaceResource.php
|
||||||
|
│ └── System/
|
||||||
|
│ └── Pages/
|
||||||
|
│ ├── Directory/
|
||||||
|
│ │ ├── Tenants.php
|
||||||
|
│ │ └── Workspaces.php
|
||||||
|
│ ├── Ops/
|
||||||
|
│ │ ├── Failures.php
|
||||||
|
│ │ ├── Runs.php
|
||||||
|
│ │ ├── Runbooks.php
|
||||||
|
│ │ └── Stuck.php
|
||||||
|
│ ├── RepairWorkspaceOwners.php
|
||||||
|
│ └── Security/
|
||||||
|
│ └── AccessLogs.php
|
||||||
|
├── Support/
|
||||||
|
│ └── Ui/
|
||||||
|
│ └── ActionSurface/
|
||||||
|
│ ├── ActionSurfaceDeclaration.php
|
||||||
|
│ ├── ActionSurfaceDiscovery.php
|
||||||
|
│ ├── ActionSurfaceExemptions.php
|
||||||
|
│ ├── ActionSurfaceProfileDefinition.php
|
||||||
|
│ ├── ActionSurfaceValidator.php
|
||||||
|
│ └── Enums/
|
||||||
|
│ ├── ActionSurfaceInspectAffordance.php
|
||||||
|
│ ├── ActionSurfacePanelScope.php
|
||||||
|
│ ├── ActionSurfaceProfile.php
|
||||||
|
│ └── ActionSurfaceType.php
|
||||||
|
docs/
|
||||||
|
├── product/
|
||||||
|
│ └── standards/
|
||||||
|
│ └── filament-actions-ux.md
|
||||||
|
└── ui/
|
||||||
|
└── action-surface-contract.md
|
||||||
|
tests/
|
||||||
|
├── Feature/
|
||||||
|
│ ├── Guards/
|
||||||
|
│ │ ├── ActionSurfaceContractTest.php
|
||||||
|
│ │ └── ActionSurfaceValidatorTest.php
|
||||||
|
│ └── Rbac/
|
||||||
|
│ └── TenantActionSurfaceConsistencyTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Keep the existing Laravel monolith structure. Extend the current action-surface support stack under `app/Support/Ui/ActionSurface`, update the enrolled reference surfaces named by the spec, sync the two developer-facing standards docs, and protect the feature through the current Pest guard suite instead of creating a new module or documentation tree.
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### Phase A — Introduce the explicit surface-type contract
|
||||||
|
|
||||||
|
**Goal**: Add one constitution-aligned enum and declaration field without creating a second action-governance framework.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| A.1 | `app/Support/Ui/ActionSurface/Enums/ActionSurfaceType.php` | Add the new first-class enum with the five enforced surface families: CRUD / List-first Resource, Read-only Registry / Report, Queue / Review, History / Audit, and Config-lite. |
|
||||||
|
| A.2 | `app/Support/Ui/ActionSurface/ActionSurfaceDeclaration.php` | Extend the declaration with a first-class `surfaceType` field and fluent/factory support, keeping `profile` as the slot-requirement model and preserving existing defaults, slots, exemptions, and metadata. |
|
||||||
|
| A.3 | `app/Support/Ui/ActionSurface/Enums/ActionSurfaceInspectAffordance.php` and `app/Support/Ui/ActionSurface/ActionSurfaceProfileDefinition.php` | Keep current affordance/profile models intact while adding the minimum helper semantics needed to evaluate allowed affordance combinations. |
|
||||||
|
|
||||||
|
### Phase B — Roll out surface types across the enrolled reference surfaces
|
||||||
|
|
||||||
|
**Goal**: Move the reference surfaces enrolled by the spec to explicit declaration-level surface typing before validator enforcement turns strict.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| B.1 | `app/Filament/Pages/Monitoring/Operations.php`, `AuditLog.php`, `FindingExceptionsQueue.php`, `EvidenceOverview.php`, and `app/Filament/Pages/Reviews/ReviewRegister.php` | Establish the primary clickable-row, explicit-inspect, and reporting-registry reference pages with explicit surface types that match the constitution. |
|
||||||
|
| B.2 | Representative CRUD and read-only registry resources such as `TenantResource`, `PolicyResource`, `BackupScheduleResource`, `BaselineProfileResource`, `WorkspaceResource`, `AlertDeliveryResource`, `BaselineSnapshotResource`, `EvidenceSnapshotResource`, `ReviewPackResource`, and `TenantReviewResource` | Align the enrolled resource reference families with the new explicit surface-type field so inspect-model and ordering checks have stable anchors. |
|
||||||
|
| B.3 | `app/Filament/Resources/OperationRunResource.php` and the enrolled system list pages under `app/Filament/System/Pages/**` | Keep the run-log and cross-panel registry references in the same rollout slice so the validator can fail on missing types without leaving the reference pack in a mixed state. |
|
||||||
|
|
||||||
|
### Phase C — Bring system-panel table pages into the primary discovery pass
|
||||||
|
|
||||||
|
**Goal**: Eliminate the split between the main validator and the targeted system-page assertions.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| C.1 | `app/Support/Ui/ActionSurface/ActionSurfaceDiscovery.php` | Add narrow discovery for `app/Filament/System/Pages/**` that includes only table-backed pages with declarations, so the six enrolled system list pages enter the primary validator. |
|
||||||
|
| C.2 | `app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php` | Preserve explicit exemptions for deferred families and verify no stale baseline exemptions remain for the six enrolled system pages. |
|
||||||
|
| C.3 | `app/Filament/System/Pages/Ops/Runs.php`, `Failures.php`, `Stuck.php`, `Directory/Tenants.php`, `Directory/Workspaces.php`, and `Security/AccessLogs.php` | Confirm these pages remain declaration-backed reference surfaces under the main validator without sweeping in auth, dashboard, runbook, or break-glass pages. |
|
||||||
|
|
||||||
|
### Phase D — Enforce inspect-model and ordering behavior in the validator and docs
|
||||||
|
|
||||||
|
**Goal**: Make the contract fail for behaviorally wrong declarations, not just missing slots.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| D.1 | `app/Support/Ui/ActionSurface/ActionSurfaceValidator.php` | Add behavior-aware rules: require explicit `surfaceType`, validate allowed inspect affordances by surface type, reject redundant lone `View` patterns on clickable-row surfaces, and require an explicit reason when `PrimaryLinkColumn` is used as an exception path. |
|
||||||
|
| D.2 | `docs/ui/action-surface-contract.md` and `docs/product/standards/filament-actions-ux.md` | Update the developer-facing reference docs together so the constitution-aligned inspect and ordering rules match the validator and the test suite. |
|
||||||
|
| D.3 | `tests/Feature/Guards/ActionSurfaceValidatorTest.php` | Extend the stub-based validator suite to cover missing `surfaceType`, invalid surface-type/affordance pairings, and required exception reasons. |
|
||||||
|
|
||||||
|
### Phase E — Add representative rendered guard coverage
|
||||||
|
|
||||||
|
**Goal**: Prove the declaration cannot claim conformance while the actual Filament table behavior drifts.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| E.1 | `tests/Feature/Guards/ActionSurfaceContractTest.php` | Add or extend rendered table tests for clickable-row references, explicit-inspect history/audit references, explicit-inspect queue/review references, reporting-registry coverage, system-panel discovery coverage, and helper-first / workflow-next / destructive-last `More` menu ordering. |
|
||||||
|
| E.2 | `tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php` | Keep one RBAC-aware tenant resource reference proving row-click and `More`-menu semantics remain aligned with capability gating and overflow placement. |
|
||||||
|
| E.3 | `vendor/bin/sail bin pint --dirty --format agent` plus focused Pest runs | Format touched files and run the narrow verification pack for validator, contract guard, and tenant action-surface consistency coverage. |
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
### D-001 — `surfaceType` is explicit and separate from `profile`
|
||||||
|
|
||||||
|
`ActionSurfaceProfile` remains the technical slot-requirement model. The new `surfaceType` is the constitution-governed behavioral classification. Keeping them separate avoids forcing slot rules and operator interaction semantics into the same enum.
|
||||||
|
|
||||||
|
### D-002 — Inspect rules stay close to the existing contract stack
|
||||||
|
|
||||||
|
The feature should not introduce a second registry or action-governance subsystem. The narrowest implementation is one new enum plus validator logic and small helper methods where needed.
|
||||||
|
|
||||||
|
### D-003 — System discovery is opt-in, not broad
|
||||||
|
|
||||||
|
System-panel coverage is achieved by discovering declared, table-backed pages under `app/Filament/System/Pages`, not by sweeping every system page into the validator. This preserves explicit exemptions for auth, dashboards, choosers, and other deferred surfaces.
|
||||||
|
|
||||||
|
### D-004 — Representative render tests enforce business-visible truth
|
||||||
|
|
||||||
|
The validator can prove declaration semantics, but it cannot prove Filament table behavior alone. Representative Livewire tests must anchor the real clickable-row, explicit-inspect, and helper-first / workflow-next / destructive-last rules.
|
||||||
|
|
||||||
|
### D-005 — `PrimaryLinkColumn` remains a justified exception path
|
||||||
|
|
||||||
|
The spec needs stronger control over linked-column inspect affordances, but not a new exception object model. A required explicit reason in the declaration is sufficient for this slice.
|
||||||
|
|
||||||
|
## Risk Assessment
|
||||||
|
|
||||||
|
| Risk | Impact | Likelihood | Mitigation |
|
||||||
|
|------|--------|------------|------------|
|
||||||
|
| Declaration rollout is incomplete when `surfaceType` becomes required | High | Medium | Introduce the contract and update the entire enrolled reference pack in the same implementation slice before making missing `surfaceType` a validator failure. |
|
||||||
|
| Discovery becomes too broad and sweeps in auth, dashboard, or deferred system surfaces | High | Medium | Limit the new discovery path to declared, table-backed system pages and preserve explicit baseline exemptions for deferred families. |
|
||||||
|
| `surfaceType` and `profile` drift semantically | Medium | Medium | Document their separate responsibilities in code, docs, and tests, and anchor each critical surface family with representative declarations. |
|
||||||
|
| More-menu ordering tests become brittle because of exact action sequences | Medium | Medium | Assert ordering invariants such as helper-first, workflow-next, destructive-last, and non-empty groups instead of pinning every menu to a full exact sequence. |
|
||||||
|
| `PrimaryLinkColumn` remains under-specified | Medium | Low | Require explicit reason text and representative validation coverage before allowing the exception path to pass. |
|
||||||
|
|
||||||
|
## Test Strategy
|
||||||
|
|
||||||
|
- Extend `tests/Feature/Guards/ActionSurfaceValidatorTest.php` with stub declarations that cover required `surfaceType`, invalid affordance combinations, and explicit exception-reason requirements.
|
||||||
|
- Extend `tests/Feature/Guards/ActionSurfaceContractTest.php` with representative Livewire coverage for Monitoring Operations or `OperationRunResource`, Audit Log, Finding Exceptions Queue, a reporting or evidence register, one CRUD `More` menu that proves helper-first, workflow-next, destructive-last ordering, and the six primary system-panel list pages discovered by the validator.
|
||||||
|
- Keep one tenant-plane RBAC-aware reference test in `tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php` so action-surface behavior remains compatible with disabled-vs-forbidden gating rules.
|
||||||
|
- Run the narrow Sail verification pack from `quickstart.md` before considering the slice complete.
|
||||||
|
- Ask whether the user wants the full suite after focused tests pass.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||||
|
|-----------|------------|-------------------------------------|
|
||||||
|
| New first-class `surfaceType` enum and declaration field | The existing `ActionSurfaceProfile` cannot distinguish constitution-level inspect behavior between queue, audit, registry, CRUD, and config-lite surfaces, so behavior-aware validation needs explicit type data. | Reusing `profile` or hiding the distinction in metadata would keep the rule implicit, make validator failures less actionable, and preserve the declaration-only gap this spec is fixing. |
|
||||||
|
|
||||||
|
## Proportionality Review
|
||||||
|
|
||||||
|
- **Current operator problem**: The repository can already prove that declarations exist, but it cannot yet prove that the declared inspect model and overflow behavior actually match the constitution. That leaves real clickable-row and explicit-inspect surfaces vulnerable to silent behavioral drift.
|
||||||
|
- **Existing structure is insufficient because**: `ActionSurfaceProfile` only describes slot requirements. It is too coarse to distinguish queue and audit surfaces that require explicit inspect from CRUD and registry surfaces that require one-click open. Primary discovery also still excludes the enrolled system-panel list pages.
|
||||||
|
- **Narrowest correct implementation**: Add one first-class `surfaceType` enum to `ActionSurfaceDeclaration`, extend discovery narrowly to the already-enrolled system table pages, and strengthen the validator plus representative rendered tests. Do not add a second registry, persistence layer, or UI framework.
|
||||||
|
- **Ownership cost created**: This adds one enum, one declaration field, more explicit declaration work on the enrolled reference surfaces, stronger validator logic, and a small set of focused guard tests that reviewers must maintain as the contract evolves.
|
||||||
|
- **Alternative intentionally rejected**: Documentation-only guidance and declaration-only slot validation were rejected because they cannot fail when rendered behavior drifts. Replacing `ActionSurfaceProfile` entirely was rejected because slot requirements and constitution surface semantics are separate concerns.
|
||||||
|
- **Release truth**: Current-release truth. The repository already contains both correct clickable-row and correct explicit-inspect patterns, and the missing work is durable enforcement.
|
||||||
74
specs/169-action-surface-v11/quickstart.md
Normal file
74
specs/169-action-surface-v11/quickstart.md
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
# Quickstart: Action Surface Contract v1.1
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Validate that the action-surface contract now governs behavior, not just declaration presence: every enrolled reference surface declares an explicit constitution-aligned `surfaceType`, the primary validator discovers the enrolled system-panel list pages, clickable-row and explicit-inspect rules are enforced, and representative `More` menus keep helpers first, workflow actions next, and destructive actions last.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. Start Sail.
|
||||||
|
2. Ensure the database and factories are available for the current test suite.
|
||||||
|
3. Keep the current baseline exemptions intact for deferred choosers, dashboards, widgets, onboarding flows, and non-enrolled system pages.
|
||||||
|
4. Ensure representative tenant-plane and system-plane test helpers continue to work:
|
||||||
|
- tenant helper for standard resources and monitoring pages
|
||||||
|
- platform helper for system-panel pages
|
||||||
|
|
||||||
|
## Implementation Validation Order
|
||||||
|
|
||||||
|
### 1. Run low-level validator coverage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceValidatorTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected outcome:
|
||||||
|
- Missing `surfaceType` fails.
|
||||||
|
- Invalid `surfaceType` and inspect-affordance combinations fail with actionable messages.
|
||||||
|
- `PrimaryLinkColumn` requires an explicit reason when used.
|
||||||
|
|
||||||
|
### 2. Run representative rendered action-surface guards
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceContractTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected outcome:
|
||||||
|
- Clickable-row references still render `recordUrl()` and do not expose redundant lone `View` actions.
|
||||||
|
- Explicit-inspect history and queue references preserve context and do not regress to row click.
|
||||||
|
- Reporting and evidence registers remain scan-first clickable-row registries rather than being misclassified as audit surfaces.
|
||||||
|
- System-panel reference pages are discovered by the primary validator without stale baseline exemptions.
|
||||||
|
- Representative `More` menus keep helpers first, workflow actions next, destructive actions last, and do not render empty placeholder groups.
|
||||||
|
|
||||||
|
### 3. Run RBAC-aware tenant reference coverage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected outcome:
|
||||||
|
- Tenant resource row-click and overflow behavior remains aligned with disabled-vs-forbidden capability semantics.
|
||||||
|
- Existing `More`-menu placement still cooperates with RBAC visibility rules.
|
||||||
|
|
||||||
|
### 4. Format touched implementation files
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail bin pint --dirty --format agent
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected outcome:
|
||||||
|
- Touched PHP files follow the repo’s Pint rules.
|
||||||
|
|
||||||
|
## Manual Smoke Check
|
||||||
|
|
||||||
|
1. Open `/admin/operations` and confirm the list still opens records through row click without a redundant row-level `View` action.
|
||||||
|
2. Open `/admin/audit-log` and confirm inspection stays explicit and context-preserving rather than row-click navigation.
|
||||||
|
3. Open `/admin/finding-exceptions/queue` and confirm inspection remains explicit while decision actions stay tied to the selected record context.
|
||||||
|
4. Open `/system/ops/runs` and `/system/directory/tenants` as a platform user and confirm those pages still behave as read-only registries while now also belonging to the main validator discovery pass.
|
||||||
|
5. Confirm deferred surfaces such as chooser pages, dashboards, widgets, and runbooks remain out of scope.
|
||||||
|
|
||||||
|
## Non-Goals For This Slice
|
||||||
|
|
||||||
|
- No new database migration or persisted artifact.
|
||||||
|
- No new asset or `filament:assets` deployment change.
|
||||||
|
- No new policy or capability family.
|
||||||
|
- No new public HTTP API; the contract artifact is internal planning documentation only.
|
||||||
57
specs/169-action-surface-v11/research.md
Normal file
57
specs/169-action-surface-v11/research.md
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
# Phase 0 Research: Action Surface Contract v1.1
|
||||||
|
|
||||||
|
## Decision: Add a first-class `ActionSurfaceType` enum to `ActionSurfaceDeclaration` while keeping `ActionSurfaceProfile`
|
||||||
|
|
||||||
|
**Rationale**: `ActionSurfaceProfile` currently governs which slots are required, but it does not distinguish constitution-level interaction semantics. The repo needs to tell the difference between clickable-row CRUD or registry surfaces and legitimate explicit-inspect queue or audit surfaces. A first-class `surfaceType` field makes that distinction explicit without forcing slot requirements and behavioral rules into the same enum.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Replace `ActionSurfaceProfile` entirely: rejected because slot requirements and constitution surface semantics are different concerns and existing declarations already rely on the profile model.
|
||||||
|
- Derive surface type from metadata or inferred heuristics: rejected because the clarified spec requires a first-class declaration-level field and because inference would keep validator failures ambiguous.
|
||||||
|
|
||||||
|
## Decision: Keep inspect-model compatibility rules close to the existing contract stack
|
||||||
|
|
||||||
|
**Rationale**: The narrowest implementation is one new enum plus validator logic and small helper methods where needed. That keeps the feature inside the current `ActionSurfaceDeclaration` / `ActionSurfaceValidator` architecture and avoids introducing a second action-governance registry or policy layer.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Create a new `ActionSurfaceTypeDefinition` registry or service: rejected because the slice does not justify another framework layer.
|
||||||
|
- Enforce the new rules in tests only: rejected because the main validator must become the primary contract gate, not just the rendered tests.
|
||||||
|
|
||||||
|
## Decision: Extend primary discovery to declared system-panel table pages only
|
||||||
|
|
||||||
|
**Rationale**: The six enrolled system list pages already have declarations and targeted tests, but the main discovery pass still excludes them. The safest extension is to scan `app/Filament/System/Pages/**` narrowly for declared, table-backed pages so the main validator covers them without accidentally enrolling auth, dashboard, widget, or other deferred system surfaces.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Hardcode an allowlist of six system classes: rejected because it would preserve a parallel discovery model and create more manual maintenance.
|
||||||
|
- Broadly discover every page under `app/Filament/System/Pages`: rejected because it would sweep in deferred surfaces such as auth, runbooks, and break-glass tooling.
|
||||||
|
|
||||||
|
## Decision: Keep current panel-scope metadata unchanged for this slice
|
||||||
|
|
||||||
|
**Rationale**: The feature needs system-panel discovery coverage, not a new panel taxonomy. The current codebase does not have an active consumer that requires a `System` panel scope enum, so adding one now would widen the slice without solving the immediate operator problem.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Add `ActionSurfacePanelScope::System`: rejected because no current validation or runtime rule depends on it, and the spec does not require it.
|
||||||
|
- Infer system scope through panel providers and extend all scope tests: rejected as unnecessary expansion beyond the current feature goal.
|
||||||
|
|
||||||
|
## Decision: Split enforcement between fast validator tests and representative Livewire render tests
|
||||||
|
|
||||||
|
**Rationale**: Validator tests are the right seam for declaration-time rules such as required `surfaceType`, allowed affordance combinations, and required exception reasons. Livewire render tests are the right seam for business-visible behavior such as row click actually existing, explicit inspect actually preserving context, and `More` groups actually rendering helpers first, workflow actions next, and destructive actions last.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Declaration-only guard coverage: rejected because the constitution explicitly rejects declaration-only conformance when rendered behavior drifts.
|
||||||
|
- Browser-only coverage: rejected because it would be slower, broader, and less precise than the current Livewire table guard pattern already used in the repo.
|
||||||
|
|
||||||
|
## Decision: Treat `PrimaryLinkColumn` as an exception path that requires an explicit reason
|
||||||
|
|
||||||
|
**Rationale**: The spec needs stronger control over linked-column inspect affordances, but the repo does not need a dedicated exception object or secondary taxonomy for that. Requiring an explicit reason on the declaration is enough to keep the exception visible and reviewable.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Allow `PrimaryLinkColumn` anywhere the enum is present: rejected because the constitution requires a concrete reason when row click is not the correct primary inspect model.
|
||||||
|
- Add a new exception class hierarchy: rejected because it would add structure without a separate current-release problem to solve.
|
||||||
|
|
||||||
|
## Decision: Keep the two reference docs in lockstep with validator behavior
|
||||||
|
|
||||||
|
**Rationale**: The repo already has two developer-facing references for this domain: `docs/ui/action-surface-contract.md` and `docs/product/standards/filament-actions-ux.md`. The feature should update both together so implementation guidance, spec language, and guard behavior stay aligned.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Update only the constitution: rejected because day-to-day implementation guidance lives in the shorter reference docs.
|
||||||
|
- Update only one doc and let the other lag: rejected because that would recreate the ambiguity the feature is meant to remove.
|
||||||
221
specs/169-action-surface-v11/spec.md
Normal file
221
specs/169-action-surface-v11/spec.md
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
# Feature Specification: Action Surface Contract v1.1: Inspect Decision Rules, Menu Ordering, and Behavior Guard Coverage
|
||||||
|
|
||||||
|
**Feature Branch**: `169-action-surface-v11`
|
||||||
|
**Created**: 2026-03-30
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Spec 169 — Action Surface Contract v1.1: Inspect Rules, Menu Ordering, and System Guard Coverage"
|
||||||
|
|
||||||
|
## Clarifications
|
||||||
|
|
||||||
|
### Session 2026-03-30
|
||||||
|
|
||||||
|
- Q: How should Spec 169 require the contract to encode surface type for enforcement? → A: Add a first-class declaration field / enum for constitution surface types and drive inspect-rule enforcement from it.
|
||||||
|
|
||||||
|
## Spec Scope Fields *(mandatory)*
|
||||||
|
|
||||||
|
- **Scope**: workspace + tenant + canonical-view + platform
|
||||||
|
- **Primary Routes**:
|
||||||
|
- `/admin/operations` and `/admin/operations/{run}` for the canonical operations list and run detail flow that now represent the clickable-row reference implementation
|
||||||
|
- `/admin/audit-log` for the history / audit surface that intentionally uses explicit inspect rather than row navigation
|
||||||
|
- `/admin/finding-exceptions/queue` for the queue / review surface that intentionally uses explicit inspect and selected-record detail actions
|
||||||
|
- `/system/ops/runs`, `/system/ops/failures`, and `/system/ops/stuck` for system-panel operational list surfaces that must be discovered by the primary contract guard
|
||||||
|
- `/system/directory/tenants`, `/system/directory/workspaces`, and `/system/security/access-logs` for system-panel registry and audit list surfaces that must be discovered by the primary contract guard
|
||||||
|
- existing CRUD / List-first resource surfaces that already declare the contract, especially the Tenants and Policies lists, as representative ordering-rule references rather than new rollout targets
|
||||||
|
- **Data Ownership**:
|
||||||
|
- No tenant-owned or workspace-owned business entity changes are introduced
|
||||||
|
- The only structural surface owned by this feature is the in-code action-surface declaration and validator contract, plus the supporting standards documentation and guard tests that govern operator-facing list behavior
|
||||||
|
- Existing tenant-owned, workspace-owned, and platform-owned records remain unchanged; this feature only governs how their list surfaces declare and enforce interaction semantics
|
||||||
|
- **RBAC**:
|
||||||
|
- Existing tenant/admin and platform authorization planes remain unchanged
|
||||||
|
- Existing workspace membership, tenant entitlement, and platform capability checks remain the enforcement source for covered pages
|
||||||
|
- Non-members or cross-plane actors remain deny-as-not-found; in-scope members without capability remain forbidden
|
||||||
|
- This feature must not weaken or bypass existing server-side authorization and must not treat UI contract enforcement as a security boundary
|
||||||
|
|
||||||
|
For canonical-view specs, the spec MUST define:
|
||||||
|
|
||||||
|
- **Default filter behavior when tenant-context is active**: Canonical and workspace-scoped list pages keep their current tenant-prefilter behavior when entered from tenant context. This feature does not broaden filter scope; it only governs inspect/open behavior and action ordering on those surfaces.
|
||||||
|
- **Explicit entitlement checks preventing cross-tenant leakage**: Existing tenant and workspace entitlement checks remain authoritative. Adding system-panel pages to the primary discovery and validation pass must not alter route visibility, record visibility, or link generation rules. Contract discovery must never imply broader access.
|
||||||
|
|
||||||
|
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Surface Type | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Monitoring Operations | Read-only Registry / Report | Full-row click to canonical run detail | required | none on the list; related actions live on the detail header | none on the list; any dangerous follow-up remains on the detail header | `/admin/operations` | `/admin/operations/{run}` | Workspace scope plus optional tenant filter | Operations / Run | run status, outcome, age, initiator | none |
|
||||||
|
| Monitoring Audit Log | History / Audit | Explicit Inspect action with same-page selected-event detail | forbidden | page header and selected-event header actions | none on the list; no destructive audit-row action | `/admin/audit-log` | `/admin/audit-log?event={event}` | Workspace scope plus tenant filter | Audit log / Audit event | outcome, actor, target, recorded time | none |
|
||||||
|
| Finding Exceptions Queue | Queue / Review | Explicit Inspect action with same-page selected-record review detail | forbidden | page header and selected-record header actions | selected-record header only; list rows stay decision-light | `/admin/finding-exceptions/queue` | `/admin/finding-exceptions/queue?exception={record}` | Workspace scope plus tenant prefilter when entered from tenant context | Finding exceptions / Exception | approval state, tenant, requested governance action, expiry urgency | none |
|
||||||
|
| Reporting and evidence registers (`Review Register`, `Evidence Overview`) | Read-only Registry / Report | Full-row click to the existing review or evidence detail destination | required | filter-reset or scope-reset helpers remain in the page header; no row-level secondary actions | none on the list | `/admin/reviews` and the existing evidence overview route | existing tenant-scoped review and evidence detail destinations | Workspace scope plus optional tenant filter or prefilter context | Reviews / Review and Evidence / Snapshot | artifact truth, completeness or freshness, generated or captured time | none |
|
||||||
|
| System operations lists (`Runs`, `Failures`, `Stuck`) | Read-only Registry / Report | Full-row click to system run detail | required | none on the list unless a system page has a justified secondary action | none on the list | `/system/ops/runs`, `/system/ops/failures`, `/system/ops/stuck` | `/system/ops/{run}` | Platform scope only | Operations / Run | run status, outcome, recency, failure/stuck signal | Cross-panel Canonical Route Exception |
|
||||||
|
| System directory lists (`Tenants`, `Workspaces`) | Read-only Registry / Report | Full-row click to system entity detail | required | none on the list unless a page-specific safe shortcut is justified | none on the list | `/system/directory/tenants`, `/system/directory/workspaces` | existing system detail pages for tenant/workspace inspection | Platform scope only | Tenants / Workspaces | identity, state, summary metadata needed for selection | none |
|
||||||
|
| System access logs | History / Audit | Explicit Inspect action or equivalent context-preserving detail open | forbidden by default | page header and selected-record context actions | none on the list | `/system/security/access-logs` | same-page selected detail or existing system detail route | Platform scope only | Access logs / Access event | actor, capability, outcome, recorded time | none |
|
||||||
|
| Representative read-only registry resources (`Operation runs`, `Alert deliveries`, `Baseline snapshots`, `Evidence snapshots`, `Review packs`, `Tenant reviews`) | Read-only Registry / Report | One-click open only, normally via row click, with at most one justified non-destructive inline shortcut | required unless a documented exception applies | no row secondary actions by default; any justified safe shortcut remains singular | detail header only, or `More` only where an existing lifecycle action already exists | existing tenant or workspace collection routes | existing view routes | existing tenant or workspace scope chips remain truthful | resource-specific canonical noun remains stable per resource | outcome, freshness, completeness, publication readiness, or artifact truth required for triage | none |
|
||||||
|
| Representative CRUD list-first resources (`Tenants`, `Policies`, `Baseline profiles`, `Backup schedules`, `Workspaces`) | CRUD / List-first Resource | One-click open only, with at most one inline safe shortcut when justified | required unless a documented exception applies | one inline safe shortcut plus `More` | `More` and detail header only | existing tenant/admin collection routes | existing view or edit routes | Workspace or tenant context chips already in use | resource-specific canonical nouns such as Tenants / Tenant, Policies / Policy, Baselines / Baseline, Backup schedules / Backup schedule, and Workspaces / Workspace | lifecycle, operability, status needed for action | none |
|
||||||
|
|
||||||
|
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Monitoring Operations | Tenant operator or workspace operator | Read-only Registry / Report | Which runs should I open, and what happened most recently? | status, outcome, operation type, initiator, start time, duration | raw context JSON, normalized counts, internal IDs, related diagnostic payloads | execution outcome, lifecycle recency | Read-only list; existing detail actions unchanged | Open run | Existing detail-level retry or resume actions only where already allowed |
|
||||||
|
| Monitoring Audit Log | Senior operator or auditor | History / Audit | Which event should I inspect without losing chronology? | outcome, actor, action, target, recorded time | full metadata payload, deeper trace context, internal identifiers | audit outcome, chronology | Read-only | Inspect event, open related record | none |
|
||||||
|
| Finding Exceptions Queue | Workspace approver or governance reviewer | Queue / Review | Which exception needs review now, and what is the right decision? | approval state, tenant, target finding, expiry or urgency, requester context | full request details, governance history, supporting raw metadata | approval lifecycle, urgency | Existing approve/reject workflow only | Inspect exception, approve, reject, open related finding | Approve/reject remain selected-detail actions with existing confirmation and authorization |
|
||||||
|
| Reporting and evidence registers | Workspace operator, reviewer, or auditor | Read-only Registry / Report | Which review or evidence record should I open next without losing scanability? | artifact truth, completeness or freshness, generated or captured time, tenant context when applicable | raw JSON, detailed supporting payloads, internal identifiers | artifact freshness, completeness, publication readiness where applicable | Read-only | Open review or evidence detail | none |
|
||||||
|
| System operations lists | Platform operator | Read-only Registry / Report | Which platform operation or failure needs investigation? | run type, status, outcome, recency, high-signal failure context | raw run payloads, internal run metadata, technical traces | execution outcome, chronology, stuck or failed state | Read-only list; existing run follow-up unchanged | Open run | Existing run follow-up actions only where already defined |
|
||||||
|
| System directory lists | Platform operator | Read-only Registry / Report | Which platform record do I need to inspect next? | name, identity, high-signal summary metadata | deeper system metadata and internal diagnostic fields | lifecycle or availability where relevant | Read-only | Open detail | none |
|
||||||
|
| Representative read-only registry resources | Tenant or workspace operator | Read-only Registry / Report | Which immutable or read-mostly record should I open next? | artifact truth, freshness, completeness, outcome, or publication readiness needed for triage | raw JSON, related payloads, deep diagnostics, internal identifiers | resource-specific truth dimensions remain separate | Read-only or tightly scoped lifecycle follow-up only | Open detail, with at most one justified safe shortcut | Existing lifecycle cleanup remains detail-header first, or `More` only where already present |
|
||||||
|
| Representative CRUD resources | Tenant or workspace operator | CRUD / List-first Resource | Which record should I open or mutate next? | identifier, health, operability, lifecycle or status needed for triage | raw IDs, low-level diagnostics, provider payload details | lifecycle, operability, state | Existing resource mutations only | Open record, one justified inline safe shortcut | Existing destructive actions remain under `More` or detail header |
|
||||||
|
|
||||||
|
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||||
|
|
||||||
|
- **New source of truth?**: No
|
||||||
|
- **New persisted entity/table/artifact?**: No
|
||||||
|
- **New abstraction?**: Yes
|
||||||
|
- **New enum/state/reason family?**: Yes. Spec 169 introduces a first-class declaration enum for constitution-aligned surface types so inspect-rule enforcement can be driven by explicit contract data instead of inference.
|
||||||
|
- **New cross-domain UI framework/taxonomy?**: No. This feature mirrors the already-approved constitution surface taxonomy instead of inventing a new product taxonomy
|
||||||
|
- **Current operator problem**: The repository can currently verify that declarations exist, but it cannot reliably verify that the declared inspect model and action-group behavior actually match the constitution. This leaves room for regressions that look compliant on paper while drifting in rendered UI behavior.
|
||||||
|
- **Existing structure is insufficient because**: `ActionSurfaceProfile` alone is too coarse to distinguish list-first clickable-row surfaces from legitimate explicit-inspect queue and audit surfaces. The main validator also does not bring system-panel list pages into the primary discovery pass.
|
||||||
|
- **Narrowest correct implementation**: Extend the existing declaration and validator contract with one first-class `surface_type` field / enum aligned to the constitution surface taxonomy, then drive inspect-model and ordering enforcement from that field. Do not build a second registry or a broad UI meta-framework.
|
||||||
|
- **Ownership cost**: This adds a small amount of declaration metadata, stronger validator logic, and representative runtime guard tests. It also raises the review bar for future list surfaces because contributors will need to declare why a surface is clickable-row, explicit-inspect, or exempt.
|
||||||
|
- **Alternative intentionally rejected**: Another manual cleanup slice or another declaration-only rollout was rejected because the repo already completed most of that work. Documentation-only guidance without guard coverage was also rejected because it would not prevent regressions.
|
||||||
|
- **Release truth**: Current-release truth. The repo already contains both correct clickable-row patterns and correct explicit-inspect patterns, and the missing work is turning those conventions into durable enforcement.
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Enforce the Correct Inspect Model (Priority: P1)
|
||||||
|
|
||||||
|
As a developer or reviewer, I need the action-surface guard to distinguish when a surface must use clickable row behavior and when it must use explicit Inspect behavior, so that new list pages cannot drift into redundant or misleading interaction models.
|
||||||
|
|
||||||
|
**Why this priority**: The constitution’s highest-value list rule is “one primary inspect/open model per list.” If the repo cannot enforce that, later rollout work becomes fragile.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by validating one clickable-row reference surface and one explicit-inspect reference surface, then proving the guard fails when their inspect models are swapped or duplicated.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a CRUD / List-first or Read-only Registry / Report surface that should open through row click, **When** it declares or renders a redundant lone `View` action, **Then** the guard fails with an actionable message.
|
||||||
|
2. **Given** a Queue / Review or History / Audit surface that should preserve context through explicit Inspect, **When** it declares row-click navigation as its primary inspect model without a documented exception, **Then** the guard fails.
|
||||||
|
3. **Given** a legitimate explicit-inspect surface such as Audit Log or Finding Exceptions Queue, **When** the guard evaluates its declaration and rendered behavior, **Then** it passes without being forced into a clickable-row model.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Enforce Stable More-Menu Ordering (Priority: P1)
|
||||||
|
|
||||||
|
As an operator, I need secondary and destructive actions to appear in a stable order across governed list surfaces, so that I can scan `More` menus without re-learning where dangerous actions are hidden.
|
||||||
|
|
||||||
|
**Why this priority**: Wave 1 normalized where actions live; the next missing layer is ensuring consistent ordering so the contract governs behavior, not just group existence.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by asserting the ordered shape of representative `More` menus on existing governed CRUD resources and ensuring inspection or navigation helpers appear first, non-destructive workflow actions appear next, destructive actions sort last, and empty groups fail.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a governed `More` menu with navigation, workflow, and destructive actions, **When** the menu is validated, **Then** inspection or navigation helpers appear first, non-destructive workflow actions appear next, and destructive actions appear last.
|
||||||
|
2. **Given** a governed surface defines an `ActionGroup` or `BulkActionGroup` placeholder with no effective actions, **When** the guard runs, **Then** the guard fails.
|
||||||
|
3. **Given** a governed surface has a justified inline safe shortcut plus a `More` menu, **When** the guard evaluates it, **Then** the shortcut remains allowed while overflow actions still follow the standard helper-first, workflow-next, destructive-last ordering rules.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Bring System Lists Under Primary Guard Coverage (Priority: P2)
|
||||||
|
|
||||||
|
As a maintainer, I need system-panel list pages to be discovered by the same primary contract validator as tenant/admin surfaces, so that the system panel is governed by the same interaction contract instead of a parallel ad hoc check.
|
||||||
|
|
||||||
|
**Why this priority**: The repo already enrolled these system pages. The remaining risk is that the main validator still ignores them, leaving a split governance model.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by proving that the primary validator discovers the enrolled system list pages and that no stale baseline exemption is required for those pages to pass.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** an enrolled system-panel table page under `app/Filament/System/Pages`, **When** the primary discovery and validator pass run, **Then** the page is discovered and validated without relying on a special targeted assertion only.
|
||||||
|
2. **Given** a system auth page, widget, or other out-of-scope surface, **When** the primary discovery pass runs, **Then** it remains out of scope unless a later spec explicitly enrolls it.
|
||||||
|
3. **Given** an enrolled system page already has a declaration, **When** the primary validator runs after this feature, **Then** no new baseline exemption is needed to keep the repo green.
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- A surface uses same-page selected-record inspect rather than full-page detail navigation; the contract must treat this as a legitimate explicit-inspect pattern, not as a clickable-row violation.
|
||||||
|
- A surface uses a primary linked column rather than full-row click because the row contains another dominant interaction model; the contract must allow this only when the declaration explains why row click is inappropriate.
|
||||||
|
- A run-log or audit surface intentionally has no bulk actions because the records are immutable; the validator must continue to allow this only through explicit exemption, not silent omission.
|
||||||
|
- System auth pages, dashboards, widgets, choosers, and deferred workspace-entry pages must not be accidentally swept into the new discovery scope.
|
||||||
|
- Existing deliberate exemptions for `ChooseTenant`, `ChooseWorkspace`, `ManagedTenantsLanding`, `TenantDashboard`, and the onboarding wizard must remain explicit and must not be “fixed” by broad enforcement.
|
||||||
|
- A surface may satisfy ordering rules declaratively but render contradictory runtime behavior; representative rendered-surface tests must catch this mismatch.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
**Constitution alignment (required):** This feature introduces no Microsoft Graph calls, no domain writes, and no new long-running or scheduled work. It is a UI governance and guard-coverage feature only. Existing list, detail, queue, audit, and system pages remain the runtime surfaces; this feature only changes how their interaction contract is documented, declared, discovered, and verified.
|
||||||
|
|
||||||
|
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature introduces one narrow contract extension and no new business persistence. The extension is a first-class code-level `surface_type` enum in `ActionSurfaceDeclaration`, aligned to the constitution surface taxonomy, because the current `ActionSurfaceProfile` cannot safely distinguish constitution-governed inspect models across CRUD, queue, audit, and registry surfaces. The implementation must remain the narrowest viable change to the existing contract stack and must not create a parallel registry, persistence layer, or new product-semantic framework.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-UX):** Not applicable. No `OperationRun` lifecycle, summary-counts contract, notification behavior, or service-owned transition rule changes are introduced.
|
||||||
|
|
||||||
|
**Constitution alignment (RBAC-UX):** This feature does not change any authorization policy, capability matrix, or 404 vs 403 semantics. It must preserve current tenant/admin and platform-plane authorization behavior on all representative test surfaces and must not convert UI contract checks into a security mechanism. Positive and negative authorization behavior on representative clickable-row and explicit-inspect surfaces remains part of regression coverage.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. No authentication handshake behavior is changed.
|
||||||
|
|
||||||
|
**Constitution alignment (BADGE-001):** This feature does not introduce or change any badge vocabulary. Existing badge semantics on governed surfaces remain centralized and unchanged.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-FIL-001):** This feature continues to use Filament-native table actions, `recordUrl()`, `ActionGroup`, and `BulkActionGroup` as the governing primitives. It must not introduce page-local replacement UI or local status language. Any behavior-aware runtime guard must inspect or test existing Filament action structures rather than replacing them.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-NAMING-001):** This feature stabilizes interaction nouns rather than introducing new product nouns. The contract must preserve the distinction between `View` and `Inspect`, keep `More` as the standard secondary-action group label, and avoid inventing alternative labels such as `Actions` or `Open` for the same contract role unless a later naming spec explicitly changes the vocabulary.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001):** This feature is a direct enforcement slice for the operator-surface constitution. It must codify the inspect decision tree for the existing surface types, document where row click is required, allowed, or forbidden, define where secondary and destructive actions live, and preserve catalogued exceptions for deferred surface families. The resulting validator and guard tests must fail when a declaration claims conformance but the rendered behavior violates the constitution.
|
||||||
|
|
||||||
|
**Constitution alignment (OPSURF-001):** This feature does not change the operator-first information hierarchy of covered pages, but it does enforce the interaction layer beneath that hierarchy. Queue and audit surfaces must preserve context through explicit inspect. List-first and registry surfaces must preserve one obvious open path. Dangerous actions must remain secondary and never compete with the primary inspect model.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** The feature must not add a second semantic layer above the existing declaration stack. It extends the existing declaration and validator model rather than layering a new presenter-like framework on top. Tests must focus on business-visible behavior rules such as “one primary inspect model,” “explicit inspect on queue/audit,” “helper-first then workflow then destructive ordering,” and “system pages actually discovered,” not on implementation indirection for its own sake.
|
||||||
|
|
||||||
|
**Constitution alignment (Filament Action Surfaces):** This feature materially changes the Action Surface Contract itself. The contract remains satisfied because no affected surface may expose more than one primary inspect model, redundant `View` actions remain forbidden where row click is canonical, empty `ActionGroup` and `BulkActionGroup` placeholders remain forbidden, and destructive actions remain secondary. Existing documented exemptions for deferred pages remain explicit. No new exemption type is introduced.
|
||||||
|
|
||||||
|
**Constitution alignment (UX-001 — Layout & Information Architecture):** No create, edit, or detail page layout is redesigned. Existing view and list layouts remain in place. This feature changes interaction contract rules and guard coverage only.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-169-001**: The system MUST codify a behavior-aware inspect decision tree for constitution surface types using the existing action-surface contract documentation and standards files.
|
||||||
|
- **FR-169-002**: `ActionSurfaceDeclaration` MUST carry a first-class `surface_type` field backed by a dedicated enum aligned to the constitution surface taxonomy.
|
||||||
|
- **FR-169-003**: The `surface_type` enum MUST explicitly distinguish at minimum these interaction classes for enforcement purposes: CRUD / List-first Resource, Read-only Registry / Report, Queue / Review, History / Audit, and Config-lite.
|
||||||
|
- **FR-169-004**: Standard CRUD / List-first and Read-only Registry / Report surfaces MUST default to one-click open behavior, normally via clickable row, unless a documented exception justifies a primary linked column.
|
||||||
|
- **FR-169-005**: Queue / Review and History / Audit surfaces MUST default to explicit Inspect or equivalent same-page selected-detail behavior and MUST NOT use row click as their primary inspect model unless a documented exception is approved.
|
||||||
|
- **FR-169-006**: Config-lite remains the only governed surface class where edit-as-inspect is allowed by default.
|
||||||
|
- **FR-169-007**: The validator and guard suite MUST fail when a surface that should be clickable-row renders a redundant lone `View` action or otherwise exposes more than one primary inspect model.
|
||||||
|
- **FR-169-008**: The validator and guard suite MUST allow legitimate explicit-inspect surfaces that preserve context, including Audit Log and Finding Exceptions Queue, without forcing them into clickable-row behavior.
|
||||||
|
- **FR-169-009**: The contract MUST define when `PrimaryLinkColumn` is allowed and MUST treat it as an exception path that requires a concrete reason row click is not the correct primary inspect model.
|
||||||
|
- **FR-169-010**: The contract MUST define ordering rules for `ActionGroup` and `BulkActionGroup` content: navigation or inspection helpers first, non-destructive lifecycle or workflow actions next, destructive actions last.
|
||||||
|
- **FR-169-011**: The guard suite MUST fail when governed `ActionGroup` or `BulkActionGroup` placeholders are empty.
|
||||||
|
- **FR-169-012**: The primary discovery pass MUST include in-scope system-panel list and table pages located under `app/Filament/System/Pages` rather than relying solely on targeted test assertions.
|
||||||
|
- **FR-169-013**: System auth pages, widgets, dashboards, choosers, and other intentionally deferred surface families MUST remain out of scope unless separately enrolled by a future spec.
|
||||||
|
- **FR-169-014**: The implementation MUST preserve the existing no-stale-exemption rule for already enrolled system pages, relation managers, monitoring pages, canonical detail pages, and singleton diagnostic pages.
|
||||||
|
- **FR-169-015**: The implementation MUST not add new baseline exemptions merely to satisfy the upgraded contract for already enrolled surface families.
|
||||||
|
- **FR-169-016**: The contract MUST keep `More` as the canonical secondary-action group label across governed surfaces unless a later naming spec changes it globally.
|
||||||
|
- **FR-169-017**: The implementation MUST update the primary developer-facing reference documents together so the behavior rules in documentation match the validator and the guard tests.
|
||||||
|
- **FR-169-018**: The guard suite MUST include representative rendered-surface tests proving that a declaration cannot claim conformance while the actual Filament table behavior violates the declared inspect model.
|
||||||
|
- **FR-169-019**: Representative rendered-surface coverage MUST include at least one clickable-row run-log surface, one explicit-inspect history/audit surface, one explicit-inspect queue/review surface, and one system-panel list surface discovered through the primary validator.
|
||||||
|
- **FR-169-020**: Representative rendered-surface coverage MUST include at least one governed `More` menu whose ordering proves helper-first, workflow-next, and destructive-last behavior.
|
||||||
|
- **FR-169-021**: The feature MUST not introduce any new domain capability, policy, migration, persisted artifact, or cross-request cache.
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Monitoring Operations | `app/Filament/Pages/Monitoring/Operations.php` and `app/Filament/Resources/OperationRunResource.php` | Existing scope and return actions such as `Scope`, `Back`, and `Show all tenants` remain | `recordUrl()` full-row click to canonical run detail | None on the list | None by explicit exemption | Existing empty-state guidance to adjust filters remains | Existing run-detail header actions such as back, refresh, related links, and resumable actions remain | Not applicable | No new audit event | This is the clickable-row reference surface for the upgraded contract |
|
||||||
|
| Monitoring Audit Log | `app/Filament/Pages/Monitoring/AuditLog.php` | Existing scope and return actions remain | Explicit `Inspect` action with same-page selected-event detail | `Inspect` only | None by explicit exemption | Existing clear-filters guidance remains | Existing selected-event related-navigation actions remain | Not applicable | No new audit event | This is the History / Audit reference surface for explicit inspect |
|
||||||
|
| Finding Exceptions Queue | `app/Filament/Pages/Monitoring/FindingExceptionsQueue.php` | Existing scope and return actions remain | Explicit `Inspect` action with same-page selected-record detail | `Inspect` only | None by explicit exemption | Existing empty-state guidance and tenant-return navigation remain | Existing `Approve exception`, `Reject exception`, and related record navigation remain | Not applicable | Yes, existing decision audit remains unchanged | This is the Queue / Review reference surface for explicit inspect |
|
||||||
|
| Reporting and evidence registers | `app/Filament/Pages/Reviews/ReviewRegister.php` and `app/Filament/Pages/Monitoring/EvidenceOverview.php` | Existing filter-reset or scope-reset helpers remain | Existing clickable-row open to review or evidence detail remains | None on the list | None by explicit exemption | Existing page-specific clear-filters guidance remains | Existing review or evidence detail actions remain on the destination surface | Not applicable | No new audit event | Representative registry surfaces enrolled by this spec so non-audit scan-first lists are covered |
|
||||||
|
| System operations lists | `app/Filament/System/Pages/Ops/Runs.php`, `Failures.php`, `Stuck.php` | Existing system-page header actions remain | Existing clickable-row open to system run detail remains | None on the list unless already justified by the page | Existing immutable-list bulk behavior or explicit exemption remains | Existing page-specific empty-state behavior remains | Existing run-detail actions remain | Not applicable | No new audit event | Read-only registry reference surfaces using the Cross-panel Canonical Route Exception while preserving the canonical Operations noun |
|
||||||
|
| System directory lists | `app/Filament/System/Pages/Directory/Tenants.php` and `Workspaces.php` | Existing system-page header actions remain | Existing clickable-row open to system detail remains | None on the list unless already justified by the page | Existing bulk behavior or explicit exemption remains | Existing page-specific empty-state behavior remains | Existing detail-page actions remain | Not applicable | No new audit event | Read-only registry reference surfaces for cross-panel guard coverage |
|
||||||
|
| System access logs | `app/Filament/System/Pages/Security/AccessLogs.php` | Existing system-page header actions remain | Existing explicit inspect or context-preserving detail open remains | Existing inspect action only | Existing immutable-list bulk behavior or explicit exemption remains | Existing page-specific empty-state behavior remains | Existing detail actions remain | Not applicable | No new audit event | History / Audit reference surface in the platform plane |
|
||||||
|
| Representative read-only registry resources | `app/Filament/Resources/AlertDeliveryResource.php`, `app/Filament/Resources/BaselineSnapshotResource.php`, `app/Filament/Resources/EvidenceSnapshotResource.php`, `app/Filament/Resources/ReviewPackResource.php`, and `app/Filament/Resources/TenantReviewResource.php` | Existing resource header actions remain unchanged | Existing row-click open remains canonical; no redundant row-level `View` action is allowed | At most one justified inline safe shortcut on resources that already expose one; otherwise none | Existing detail-header lifecycle actions remain preferred; row-level destructive actions remain secondary only where already justified | Existing page-specific empty-state behavior remains | Existing view-header actions remain | Not applicable | No new audit event | Representative read-only registry resource family for v1.1 declaration rollout |
|
||||||
|
| Representative CRUD list-first resources (`Tenants`, `Policies`, `Baseline profiles`, `Backup schedules`, `Workspaces`) | `app/Filament/Resources/TenantResource.php`, `app/Filament/Resources/PolicyResource.php`, `app/Filament/Resources/BaselineProfileResource.php`, `app/Filament/Resources/BackupScheduleResource.php`, and `app/Filament/Resources/Workspaces/WorkspaceResource.php` | Existing scope and sync or lifecycle header actions remain | Existing row-click open remains canonical | One justified inline safe shortcut plus `More` | Existing bulk actions remain grouped under `More`; destructive actions stay under `More` or the detail header | Existing empty-state CTA behavior remains | Existing view or edit header actions remain | Not applicable | No new audit event | This is the representative CRUD family for overflow ordering and inline-budget enforcement |
|
||||||
|
|
||||||
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **ActionSurfaceDeclaration v1.1**: The existing declaration object extended with the minimum rule data needed to enforce inspect-model and ordering behavior.
|
||||||
|
- **Surface Type Enum**: A first-class enum on `ActionSurfaceDeclaration` aligned to the constitution surface taxonomy and used as the enforcement source for inspect-model rules.
|
||||||
|
- **Governed Surface Type**: The constitution-aligned interaction class used to decide whether a surface should be clickable-row, explicit-inspect, or edit-as-inspect.
|
||||||
|
- **Inspect Decision Rule**: The rule set that maps governed surface type to the allowed primary inspect model.
|
||||||
|
- **Action Ordering Rule**: The rule set that governs the order and legality of entries inside `ActionGroup` and `BulkActionGroup` on covered surfaces.
|
||||||
|
- **Primary Discovery Scope**: The set of Filament Resources, Pages, RelationManagers, and system-panel pages included in the repository-wide action-surface validation pass.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-169-001**: 100% of the enrolled system-panel list pages covered by existing targeted tests are discovered by the primary validator after this feature ships.
|
||||||
|
- **SC-169-002**: Representative guard coverage exists for all four critical rule families: clickable-row enforcement, explicit-inspect enforcement, ordered overflow behavior, and out-of-scope exemption preservation.
|
||||||
|
- **SC-169-003**: A deliberately broken inspect-model or ordering implementation fails with an actionable message naming the violating class and the violated rule.
|
||||||
|
- **SC-169-004**: Existing reference surfaces continue to pass under the upgraded rules: clickable-row `OperationRunResource` / Monitoring Operations, explicit-inspect Audit Log, explicit-inspect Finding Exceptions Queue, and at least one system-panel list surface.
|
||||||
|
- **SC-169-005**: No new baseline exemption is added for already enrolled system pages or relation managers in order to make the upgraded contract pass.
|
||||||
|
- **SC-169-006**: The feature ships without introducing any new business persistence, any new capability family, or any product-facing route or workflow change.
|
||||||
198
specs/169-action-surface-v11/tasks.md
Normal file
198
specs/169-action-surface-v11/tasks.md
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
# Tasks: Action Surface Contract v1.1
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/169-action-surface-v11/`
|
||||||
|
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/action-surface-governance.logical.openapi.yaml, quickstart.md
|
||||||
|
|
||||||
|
**Tests**: Required. This feature changes runtime behavior and repository guards, so Pest and Livewire coverage must be added and run.
|
||||||
|
|
||||||
|
**Organization**: Tasks are grouped by user story so each story can be implemented and validated independently once the blocking foundation work is complete.
|
||||||
|
|
||||||
|
## Phase 1: Setup (Shared Infrastructure)
|
||||||
|
|
||||||
|
**Purpose**: Add the minimum shared contract scaffolding that every later story depends on.
|
||||||
|
|
||||||
|
- [X] T001 Create the first-class `ActionSurfaceType` enum in `app/Support/Ui/ActionSurface/Enums/ActionSurfaceType.php`
|
||||||
|
- [X] T002 Extend `app/Support/Ui/ActionSurface/ActionSurfaceDeclaration.php` to require `surfaceType` and store `PrimaryLinkColumn` reason metadata alongside the existing profile, slots, exemptions, and defaults
|
||||||
|
- [X] T003 [P] Update shared action-surface helper seams in `app/Support/Ui/ActionSurface/Enums/ActionSurfaceInspectAffordance.php` and `app/Support/Ui/ActionSurface/ActionSurfaceProfileDefinition.php` so the new surface-type contract can be referenced consistently
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Prerequisites)
|
||||||
|
|
||||||
|
**Purpose**: Roll the new declaration field across the enrolled reference surfaces before strict validator enforcement begins.
|
||||||
|
|
||||||
|
**⚠️ CRITICAL**: No story-specific enforcement work should begin until every enrolled reference surface can compile with the new `surfaceType` contract.
|
||||||
|
|
||||||
|
- [X] T004 [P] Add explicit `surfaceType` declarations to page-backed monitoring and reporting references in `app/Filament/Pages/Monitoring/Operations.php`, `app/Filament/Pages/Monitoring/AuditLog.php`, `app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`, `app/Filament/Pages/Monitoring/EvidenceOverview.php`, and `app/Filament/Pages/Reviews/ReviewRegister.php`
|
||||||
|
- [X] T005 [P] Add explicit `surfaceType` declarations to representative CRUD resources in `app/Filament/Resources/BackupScheduleResource.php`, `app/Filament/Resources/BaselineProfileResource.php`, `app/Filament/Resources/PolicyResource.php`, `app/Filament/Resources/TenantResource.php`, and `app/Filament/Resources/Workspaces/WorkspaceResource.php`
|
||||||
|
- [X] T006 [P] Add explicit `surfaceType` declarations to representative read-only registry resources in `app/Filament/Resources/OperationRunResource.php`, `app/Filament/Resources/AlertDeliveryResource.php`, `app/Filament/Resources/BaselineSnapshotResource.php`, `app/Filament/Resources/EvidenceSnapshotResource.php`, `app/Filament/Resources/ReviewPackResource.php`, and `app/Filament/Resources/TenantReviewResource.php`
|
||||||
|
- [X] T007 [P] Add explicit `surfaceType` declarations to the enrolled system list pages in `app/Filament/System/Pages/Ops/Runs.php`, `app/Filament/System/Pages/Ops/Failures.php`, `app/Filament/System/Pages/Ops/Stuck.php`, `app/Filament/System/Pages/Directory/Tenants.php`, `app/Filament/System/Pages/Directory/Workspaces.php`, and `app/Filament/System/Pages/Security/AccessLogs.php`
|
||||||
|
|
||||||
|
**Checkpoint**: The enrolled reference pack is migrated to the v1.1 contract and story-specific guard work can begin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 - Enforce the Correct Inspect Model (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: Make the validator and representative guard surfaces fail when clickable-row and explicit-inspect semantics drift from the constitution.
|
||||||
|
|
||||||
|
**Independent Test**: Prove one clickable-row reference surface and one explicit-inspect reference surface pass, then prove the guard fails when their inspect models are swapped or duplicated.
|
||||||
|
|
||||||
|
### Tests for User Story 1
|
||||||
|
|
||||||
|
- [X] T008 [P] [US1] Extend `tests/Feature/Guards/ActionSurfaceValidatorTest.php` with failing cases for missing `surfaceType`, incompatible inspect-affordance pairings, and missing `PrimaryLinkColumn` reason text
|
||||||
|
- [X] T009 [US1] Extend `tests/Feature/Guards/ActionSurfaceContractTest.php` with failing rendered-behavior checks for clickable-row references and explicit-inspect references using `app/Filament/Pages/Monitoring/Operations.php`, `app/Filament/Resources/OperationRunResource.php`, `app/Filament/Pages/Monitoring/AuditLog.php`, `app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`, `app/Filament/Pages/Reviews/ReviewRegister.php`, and `app/Filament/Pages/Monitoring/EvidenceOverview.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [X] T010 [US1] Implement surface-type inspect compatibility and actionable validation messages in `app/Support/Ui/ActionSurface/ActionSurfaceValidator.php`
|
||||||
|
- [X] T011 [US1] Align the inspect-model reference declarations in `app/Filament/Pages/Monitoring/Operations.php`, `app/Filament/Resources/OperationRunResource.php`, `app/Filament/Pages/Monitoring/AuditLog.php`, `app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`, `app/Filament/Pages/Reviews/ReviewRegister.php`, and `app/Filament/Pages/Monitoring/EvidenceOverview.php` with the constitution decision tree and explicit exception metadata
|
||||||
|
- [X] T012 [US1] Update inspect-model guidance in `docs/ui/action-surface-contract.md` and `docs/product/standards/filament-actions-ux.md` to codify clickable-row defaults, explicit-inspect requirements, reporting-registry coverage, and `PrimaryLinkColumn` exception rules
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 1 is complete when inspect-model drift fails in both validator stubs and representative rendered guards while the enrolled reference surfaces continue to pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 - Enforce Stable More-Menu Ordering (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: Make representative governed lists prove helper-first, workflow-next, destructive-last ordering and prevent empty overflow groups from surviving as placeholders.
|
||||||
|
|
||||||
|
**Independent Test**: Assert the ordered `More` and `BulkActionGroup` shape on representative CRUD surfaces and fail the guard when helpers do not lead, workflow actions trail destructive ones, or groups become empty placeholders.
|
||||||
|
|
||||||
|
### Tests for User Story 2
|
||||||
|
|
||||||
|
- [X] T013 [US2] Extend `tests/Feature/Guards/ActionSurfaceContractTest.php` with failing helper-first, workflow-next, destructive-last, and empty-group assertions for representative `More` and `BulkActionGroup` surfaces
|
||||||
|
- [X] T014 [P] [US2] Extend `tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php` with RBAC-aware overflow ordering assertions for tenant list surfaces
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [X] T015 [P] [US2] Reorder secondary and destructive actions in `app/Filament/Resources/BackupScheduleResource.php` and `app/Filament/Resources/BaselineProfileResource.php` so inspection helpers lead, workflow actions follow, destructive actions stay last, and placeholder groups cannot render
|
||||||
|
- [X] T016 [P] [US2] Align inline safe shortcut budgets and `More` menu placement in `app/Filament/Resources/TenantResource.php`, `app/Filament/Resources/PolicyResource.php`, and `app/Filament/Resources/Workspaces/WorkspaceResource.php`
|
||||||
|
- [X] T017 [US2] Update helper-first, workflow-next, destructive-last, and placeholder-group guidance in `docs/product/standards/filament-actions-ux.md` and `docs/ui/action-surface-contract.md`
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 2 is complete when representative CRUD and RBAC-aware list surfaces render stable overflow ordering with helpers first, workflow actions next, destructive actions last, and no empty groups.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 - Bring System Lists Under Primary Guard Coverage (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Move the enrolled system-panel list pages from targeted-only assertions into the main repository-wide discovery and validator pass.
|
||||||
|
|
||||||
|
**Independent Test**: Prove the primary validator discovers the six enrolled system list pages and still excludes auth, dashboard, widget, chooser, and deferred system tooling surfaces.
|
||||||
|
|
||||||
|
### Tests for User Story 3
|
||||||
|
|
||||||
|
- [X] T018 [US3] Extend `tests/Feature/Guards/ActionSurfaceContractTest.php` with failing discovery assertions for the six enrolled system list pages and explicit exclusion assertions for `app/Filament/System/Pages/Ops/Runbooks.php` and `app/Filament/System/Pages/RepairWorkspaceOwners.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [X] T019 [US3] Implement narrow system table-page discovery in `app/Support/Ui/ActionSurface/ActionSurfaceDiscovery.php` for declared `app/Filament/System/Pages/**` table pages only
|
||||||
|
- [X] T020 [US3] Update baseline exemption handling in `app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php` so enrolled system pages are no longer treated like deferred exemptions and deferred families remain explicit
|
||||||
|
- [X] T021 [P] [US3] Tune the enrolled system reference declarations in `app/Filament/System/Pages/Ops/Runs.php`, `app/Filament/System/Pages/Ops/Failures.php`, `app/Filament/System/Pages/Ops/Stuck.php`, `app/Filament/System/Pages/Directory/Tenants.php`, `app/Filament/System/Pages/Directory/Workspaces.php`, and `app/Filament/System/Pages/Security/AccessLogs.php` for the new discovery path, the repaired `ReadOnlyRegistryReport` classification for system ops lists, and canonical `Operations / Run` naming
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 3 is complete when the primary validator discovers the enrolled system list pages without stale baseline exemptions and still excludes deferred system surfaces.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Run the focused verification and formatting steps that close the implementation loop.
|
||||||
|
|
||||||
|
- [X] T022 Run `vendor/bin/sail bin pint --dirty --format agent`
|
||||||
|
- [X] T023 Run the focused verification pack from `specs/169-action-surface-v11/quickstart.md` against `tests/Feature/Guards/ActionSurfaceValidatorTest.php`, `tests/Feature/Guards/ActionSurfaceContractTest.php`, and `tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Setup (Phase 1)**: Starts immediately.
|
||||||
|
- **Foundational (Phase 2)**: Depends on Setup completion and blocks all user stories.
|
||||||
|
- **User Story 1 (Phase 3)**: Starts after Phase 2.
|
||||||
|
- **User Story 2 (Phase 4)**: Starts after Phase 2 and can proceed independently of US1 at the feature level, though both stories touch shared docs and guard files.
|
||||||
|
- **User Story 3 (Phase 5)**: Starts after Phase 2 and can proceed independently of US1 and US2 at the feature level, though it shares `tests/Feature/Guards/ActionSurfaceContractTest.php`.
|
||||||
|
- **Polish (Phase 6)**: Starts after all desired user stories are complete.
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **US1 (P1)**: Depends on Setup and Foundational only.
|
||||||
|
- **US2 (P1)**: Depends on Setup and Foundational only.
|
||||||
|
- **US3 (P2)**: Depends on Setup and Foundational only.
|
||||||
|
|
||||||
|
### Within Each User Story
|
||||||
|
|
||||||
|
- Story tests are written or extended before story implementation tasks.
|
||||||
|
- Shared validator or discovery code changes come before story-level declaration tuning.
|
||||||
|
- Reference surfaces are aligned before the focused verification pack runs.
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
- T003 can run in parallel with T001 or T002 once the enum shape is settled.
|
||||||
|
- T004 through T007 can run in parallel because they touch different declaration families.
|
||||||
|
- In US1, T008 can run in parallel with declaration tuning preparation because it targets a separate test file.
|
||||||
|
- In US2, T014, T015, and T016 can run in parallel because they target different files.
|
||||||
|
- In US3, T021 can run in parallel with T019 or T020 once the discovery rule is agreed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: User Story 1
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Launch the validator stub work and rendered guard extension separately:
|
||||||
|
Task: "Extend tests/Feature/Guards/ActionSurfaceValidatorTest.php with failing cases for missing surfaceType and invalid inspect-affordance pairings"
|
||||||
|
Task: "Extend tests/Feature/Guards/ActionSurfaceContractTest.php with failing rendered-behavior checks for Monitoring Operations, OperationRunResource, AuditLog, FindingExceptionsQueue, ReviewRegister, and EvidenceOverview"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Example: User Story 2
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Split ordering work across resource families:
|
||||||
|
Task: "Reorder secondary and destructive actions in app/Filament/Resources/BackupScheduleResource.php and app/Filament/Resources/BaselineProfileResource.php so helpers lead, workflow actions follow, and destructive actions stay last"
|
||||||
|
Task: "Align inline safe shortcut budgets and More menu placement in app/Filament/Resources/TenantResource.php, app/Filament/Resources/PolicyResource.php, and app/Filament/Resources/Workspaces/WorkspaceResource.php"
|
||||||
|
Task: "Extend tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php with RBAC-aware overflow ordering assertions"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Example: User Story 3
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Let discovery and system declaration tuning proceed side by side:
|
||||||
|
Task: "Implement narrow system table-page discovery in app/Support/Ui/ActionSurface/ActionSurfaceDiscovery.php"
|
||||||
|
Task: "Tune the enrolled system reference declarations in app/Filament/System/Pages/Ops/Runs.php, app/Filament/System/Pages/Ops/Failures.php, app/Filament/System/Pages/Ops/Stuck.php, app/Filament/System/Pages/Directory/Tenants.php, app/Filament/System/Pages/Directory/Workspaces.php, and app/Filament/System/Pages/Security/AccessLogs.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 the focused inspect-model guard behavior before starting additional stories.
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Finish Setup + Foundational to put the enrolled reference pack on the v1.1 declaration contract.
|
||||||
|
2. Deliver US1 to make inspect-model drift fail decisively.
|
||||||
|
3. Deliver US2 to stabilize overflow ordering across representative CRUD surfaces.
|
||||||
|
4. Deliver US3 to bring system lists into the main validator scope.
|
||||||
|
5. Run the focused quickstart verification and then decide whether to run the full suite.
|
||||||
|
|
||||||
|
### Parallel Team Strategy
|
||||||
|
|
||||||
|
1. One contributor handles Phase 1 and the shared declaration contract updates.
|
||||||
|
2. After Phase 2, separate contributors can take:
|
||||||
|
- US1 validator and monitoring reference surfaces
|
||||||
|
- US2 CRUD ordering surfaces and RBAC-aware overflow tests
|
||||||
|
- US3 system discovery and system reference surfaces
|
||||||
|
3. Rejoin for Phase 6 formatting and focused verification.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `[P]` tasks touch separate files and can be executed in parallel after their dependencies are satisfied.
|
||||||
|
- The main shared hot spots are `tests/Feature/Guards/ActionSurfaceContractTest.php`, `docs/ui/action-surface-contract.md`, and `docs/product/standards/filament-actions-ux.md`; avoid parallel edits there.
|
||||||
|
- This feature does not add `OperationRun`, assets, routes, persistence, or capability work, so no extra Ops-UX or deployment tasks are required.
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
# Specification Quality Checklist: System Operations Surface Alignment
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-03-30
|
||||||
|
**Feature**: [spec.md](../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: complete
|
||||||
|
- This spec intentionally excludes naming consolidation and deferred dashboard or onboarding retrofits, which are reserved for Specs 171 and 172.
|
||||||
@ -0,0 +1,192 @@
|
|||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
title: System Operations Surface Alignment Contract
|
||||||
|
version: 1.0.0
|
||||||
|
summary: Route and UI contract for Spec 170.
|
||||||
|
paths:
|
||||||
|
/system/ops/runs:
|
||||||
|
get:
|
||||||
|
operationId: listSystemRuns
|
||||||
|
summary: Display the platform-wide operations registry.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: System runs list rendered successfully.
|
||||||
|
'403':
|
||||||
|
description: Authenticated platform user lacks operations view capability.
|
||||||
|
'404':
|
||||||
|
description: Wrong plane or inaccessible system surface.
|
||||||
|
x-ui-surface:
|
||||||
|
surfaceType: read_only_registry_report
|
||||||
|
displayLabel: Operations
|
||||||
|
canonicalNoun:
|
||||||
|
collection: Operations
|
||||||
|
singular: Operation
|
||||||
|
inspectAffordance: clickable_row
|
||||||
|
canonicalDetailRoute: /system/ops/runs/{run}
|
||||||
|
headerActions:
|
||||||
|
- name: go_to_runbooks
|
||||||
|
label: Go to runbooks
|
||||||
|
type: navigation
|
||||||
|
rowActions: []
|
||||||
|
bulkActions: []
|
||||||
|
emptyStateCta:
|
||||||
|
name: go_to_runbooks
|
||||||
|
label: Go to runbooks
|
||||||
|
requiredCapabilities:
|
||||||
|
- platform.operations.view
|
||||||
|
defaultVisibleTruth:
|
||||||
|
- status
|
||||||
|
- outcome
|
||||||
|
- operation
|
||||||
|
- workspace
|
||||||
|
- tenant
|
||||||
|
- initiator
|
||||||
|
- activity_time
|
||||||
|
/system/ops/failures:
|
||||||
|
get:
|
||||||
|
operationId: listSystemFailures
|
||||||
|
summary: Display failed operations as a read-only registry.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Failed-runs list rendered successfully.
|
||||||
|
'403':
|
||||||
|
description: Authenticated platform user lacks operations view capability.
|
||||||
|
'404':
|
||||||
|
description: Wrong plane or inaccessible system surface.
|
||||||
|
x-ui-surface:
|
||||||
|
surfaceType: read_only_registry_report
|
||||||
|
displayLabel: Failed operations
|
||||||
|
canonicalNoun:
|
||||||
|
collection: Operations
|
||||||
|
singular: Operation
|
||||||
|
inspectAffordance: clickable_row
|
||||||
|
canonicalDetailRoute: /system/ops/runs/{run}
|
||||||
|
headerActions:
|
||||||
|
- name: show_all_operations
|
||||||
|
label: Show all operations
|
||||||
|
type: navigation
|
||||||
|
url: /system/ops/runs
|
||||||
|
rowActions: []
|
||||||
|
bulkActions: []
|
||||||
|
emptyStateCta:
|
||||||
|
name: show_all_operations
|
||||||
|
label: Show all operations
|
||||||
|
requiredCapabilities:
|
||||||
|
- platform.operations.view
|
||||||
|
filter:
|
||||||
|
status: completed
|
||||||
|
outcome: failed
|
||||||
|
defaultVisibleTruth:
|
||||||
|
- status
|
||||||
|
- outcome
|
||||||
|
- operation
|
||||||
|
- workspace
|
||||||
|
- tenant
|
||||||
|
- activity_time
|
||||||
|
/system/ops/stuck:
|
||||||
|
get:
|
||||||
|
operationId: listSystemStuckRuns
|
||||||
|
summary: Display queued or running operations that exceed the stuck threshold.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Stuck-runs list rendered successfully.
|
||||||
|
'403':
|
||||||
|
description: Authenticated platform user lacks operations view capability.
|
||||||
|
'404':
|
||||||
|
description: Wrong plane or inaccessible system surface.
|
||||||
|
x-ui-surface:
|
||||||
|
surfaceType: read_only_registry_report
|
||||||
|
displayLabel: Stuck operations
|
||||||
|
canonicalNoun:
|
||||||
|
collection: Operations
|
||||||
|
singular: Operation
|
||||||
|
inspectAffordance: clickable_row
|
||||||
|
canonicalDetailRoute: /system/ops/runs/{run}
|
||||||
|
headerActions:
|
||||||
|
- name: show_all_operations
|
||||||
|
label: Show all operations
|
||||||
|
type: navigation
|
||||||
|
url: /system/ops/runs
|
||||||
|
rowActions: []
|
||||||
|
bulkActions: []
|
||||||
|
emptyStateCta:
|
||||||
|
name: show_all_operations
|
||||||
|
label: Show all operations
|
||||||
|
requiredCapabilities:
|
||||||
|
- platform.operations.view
|
||||||
|
derivedFields:
|
||||||
|
- stuck_class
|
||||||
|
defaultVisibleTruth:
|
||||||
|
- status
|
||||||
|
- stuck_class
|
||||||
|
- operation
|
||||||
|
- workspace
|
||||||
|
- tenant
|
||||||
|
- activity_time
|
||||||
|
/system/ops/runs/{run}:
|
||||||
|
get:
|
||||||
|
operationId: viewSystemRun
|
||||||
|
summary: Display one system operation detail surface.
|
||||||
|
parameters:
|
||||||
|
- name: run
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: System run detail rendered successfully.
|
||||||
|
'403':
|
||||||
|
description: Authenticated platform user lacks operations view capability.
|
||||||
|
'404':
|
||||||
|
description: Wrong plane or inaccessible system surface.
|
||||||
|
x-ui-surface:
|
||||||
|
surfaceType: detail_first_operational
|
||||||
|
displayLabel: Operation
|
||||||
|
canonicalNoun:
|
||||||
|
collection: Operations
|
||||||
|
singular: Operation
|
||||||
|
requiredCapabilities:
|
||||||
|
- platform.operations.view
|
||||||
|
headerActions:
|
||||||
|
- name: show_all_operations
|
||||||
|
label: Show all operations
|
||||||
|
type: navigation
|
||||||
|
url: /system/ops/runs
|
||||||
|
- name: go_to_runbooks
|
||||||
|
label: Go to runbooks
|
||||||
|
type: navigation
|
||||||
|
- name: retry
|
||||||
|
label: Retry
|
||||||
|
type: mutation
|
||||||
|
requiredCapabilities:
|
||||||
|
- platform.operations.manage
|
||||||
|
confirmationRequired: true
|
||||||
|
visibleWhen:
|
||||||
|
status: completed
|
||||||
|
outcome: failed
|
||||||
|
retryableType: true
|
||||||
|
- name: cancel
|
||||||
|
label: Cancel
|
||||||
|
type: destructive_mutation
|
||||||
|
requiredCapabilities:
|
||||||
|
- platform.operations.manage
|
||||||
|
confirmationRequired: true
|
||||||
|
visibleWhen:
|
||||||
|
statusIn:
|
||||||
|
- queued
|
||||||
|
- running
|
||||||
|
cancelableType: true
|
||||||
|
- name: mark_investigated
|
||||||
|
label: Mark investigated
|
||||||
|
type: mutation
|
||||||
|
requiredCapabilities:
|
||||||
|
- platform.operations.manage
|
||||||
|
confirmationRequired: true
|
||||||
|
form:
|
||||||
|
fields:
|
||||||
|
- name: reason
|
||||||
|
type: textarea
|
||||||
|
required: true
|
||||||
|
minLength: 5
|
||||||
|
maxLength: 500
|
||||||
110
specs/170-system-operations-surface-alignment/data-model.md
Normal file
110
specs/170-system-operations-surface-alignment/data-model.md
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
# Data Model: System Operations Surface Alignment
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This feature introduces no new persisted entity, no new table, and no new status family. It reuses existing `OperationRun` truth and existing platform capability checks, while narrowing where triage actions are exposed in the UI.
|
||||||
|
|
||||||
|
## Entity: OperationRun
|
||||||
|
|
||||||
|
- **Type**: Existing persisted model
|
||||||
|
- **Purpose in this feature**: Canonical record shown on the three system list pages and on the system run detail page.
|
||||||
|
|
||||||
|
### Relevant Fields
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|-------|------|-------|
|
||||||
|
| `id` | integer | Displayed as `Operation #<id>` and used in canonical detail routing. |
|
||||||
|
| `workspace_id` | integer | Required for platform-wide run context. |
|
||||||
|
| `tenant_id` | integer nullable | Nullable for tenantless/platform runs; still shown on system lists and detail. |
|
||||||
|
| `initiator_name` | string nullable | Default-visible operator truth on the all-runs list. |
|
||||||
|
| `type` | string | Rendered through `OperationCatalog::label(...)`. |
|
||||||
|
| `status` | string | Badge-rendered lifecycle dimension. |
|
||||||
|
| `outcome` | string | Badge-rendered execution-outcome dimension. |
|
||||||
|
| `context` | array/json | Stores triage metadata such as retry, cancel, and investigation context. |
|
||||||
|
| `created_at` | timestamp | Used for default-visible list activity time and recency on all three lists. |
|
||||||
|
| `started_at` | timestamp nullable | Supports stuck classification and detail timing. |
|
||||||
|
| `completed_at` | timestamp nullable | Preserved for completed/failed runs and detail history. |
|
||||||
|
|
||||||
|
### Relationships
|
||||||
|
|
||||||
|
| Relationship | Target | Purpose |
|
||||||
|
|--------------|--------|---------|
|
||||||
|
| `workspace` | `Workspace` | Default-visible context on all system lists and on the detail page. |
|
||||||
|
| `tenant` | `Tenant` | Default-visible context on all system lists and on the detail page. |
|
||||||
|
|
||||||
|
### Feature-Specific Invariants
|
||||||
|
|
||||||
|
- All three system lists eager-load `tenant` and `workspace`.
|
||||||
|
- `Runs` shows the full platform-wide run set.
|
||||||
|
- `Failures` filters to `status=completed` and `outcome=failed`.
|
||||||
|
- `Stuck` filters through `StuckRunClassifier` and exposes a derived `stuck_class` label.
|
||||||
|
- List inspection always resolves to `SystemOperationRunLinks::view($run)`.
|
||||||
|
|
||||||
|
### State Transitions Used By This Feature
|
||||||
|
|
||||||
|
| Transition | Preconditions | Result |
|
||||||
|
|------------|---------------|--------|
|
||||||
|
| Inspect list row | Operator has `platform.operations.view` | No state change; opens canonical detail page. |
|
||||||
|
| Retry run | Run is completed, failed, and retryable | Creates a new queued `OperationRun` with `context.triage.retry_of_run_id` and audit action `platform.system_console.retry`. |
|
||||||
|
| Cancel run | Run is queued or running and cancelable | Updates the current run to completed/failed through `OperationRunService` and logs `platform.system_console.cancel`. |
|
||||||
|
| Mark investigated | Operator has manage capability and supplies a valid reason | Updates `context.triage.investigated` on the current run and logs `platform.system_console.mark_investigated`. |
|
||||||
|
|
||||||
|
## Entity: PlatformUser Capability Gate
|
||||||
|
|
||||||
|
- **Type**: Existing persisted/authenticated actor model
|
||||||
|
- **Purpose in this feature**: Separates inspection access from triage access on system-panel surfaces.
|
||||||
|
|
||||||
|
### Relevant Capability Fields
|
||||||
|
|
||||||
|
| Capability | Purpose |
|
||||||
|
|------------|---------|
|
||||||
|
| `platform.access_system_panel` | Required to enter the system panel. |
|
||||||
|
| `platform.operations.view` | Required to access the system runs/failures/stuck/detail surfaces. |
|
||||||
|
| `platform.operations.manage` | Required to see and execute triage actions on the detail page. |
|
||||||
|
|
||||||
|
### Feature-Specific Invariants
|
||||||
|
|
||||||
|
- View-only users can load list and detail pages but cannot see triage actions.
|
||||||
|
- Manage-capable users retain retry/cancel/mark-investigated on the detail header.
|
||||||
|
- No new capability is introduced.
|
||||||
|
|
||||||
|
## Derived UI Contract: System Operations Lists
|
||||||
|
|
||||||
|
- **Type**: Derived UI surface, not persisted
|
||||||
|
- **Routes**:
|
||||||
|
- `/system/ops/runs`
|
||||||
|
- `/system/ops/failures`
|
||||||
|
- `/system/ops/stuck`
|
||||||
|
- **Surface Type**: `ReadOnlyRegistryReport`
|
||||||
|
- **Primary Question**: Which operation should I open next?
|
||||||
|
|
||||||
|
### Contract Rules
|
||||||
|
|
||||||
|
- Primary inspect affordance is full-row click only.
|
||||||
|
- Row actions are empty.
|
||||||
|
- Bulk actions are empty.
|
||||||
|
- Visible collection naming uses `Operations` and visible singular naming uses `Operation`.
|
||||||
|
- `/system/ops/runs` uses `Go to runbooks` as its single header and empty-state CTA.
|
||||||
|
- `/system/ops/failures` and `/system/ops/stuck` use `Show all operations` as their single header and empty-state CTA.
|
||||||
|
|
||||||
|
## Derived UI Contract: System Run Detail
|
||||||
|
|
||||||
|
- **Type**: Derived UI surface, not persisted
|
||||||
|
- **Route**: `/system/ops/runs/{run}`
|
||||||
|
- **Surface Type**: Detail-first operational surface
|
||||||
|
- **Primary Question**: What happened on this operation, and what follow-up is appropriate?
|
||||||
|
|
||||||
|
### Contract Rules
|
||||||
|
|
||||||
|
- Header actions remain the only triage location for retry, cancel, and mark investigated.
|
||||||
|
- The detail surface exposes `Show all operations` as the canonical return path and keeps `Go to runbooks` available as secondary navigation.
|
||||||
|
- `cancel` remains destructive and confirmation-gated.
|
||||||
|
- `mark investigated` retains the `reason` validation rule: required, minimum 5 characters, maximum 500 characters.
|
||||||
|
- No new page, modal surface, or alternate triage route is introduced.
|
||||||
|
|
||||||
|
## Persistence Impact
|
||||||
|
|
||||||
|
- **Schema changes**: None
|
||||||
|
- **Data migration**: None
|
||||||
|
- **New indexes**: None
|
||||||
|
- **Retention impact**: None
|
||||||
127
specs/170-system-operations-surface-alignment/plan.md
Normal file
127
specs/170-system-operations-surface-alignment/plan.md
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
# Implementation Plan: System Operations Surface Alignment
|
||||||
|
|
||||||
|
**Branch**: `170-system-operations-surface-alignment` | **Date**: 2026-03-30 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/170-system-operations-surface-alignment/spec.md`
|
||||||
|
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/170-system-operations-surface-alignment/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Align the system-panel Operations, Failed operations, and Stuck operations pages to their declared `ReadOnlyRegistryReport` surface semantics by removing inline triage from the list rows, preserving full-row navigation to the canonical system operation detail page, standardizing visible naming to `Operations` / `Operation`, and adding the required list CTA and detail return-path behavior while keeping retry/cancel/mark-investigated ownership on the existing `ViewRun` page. The implementation stays narrow: no schema, new capability, or service-model changes; only Filament page behavior, visible operator copy, and the associated Pest/Livewire guard coverage are updated.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4, Laravel 12, Livewire v4, Filament v5
|
||||||
|
**Primary Dependencies**: `laravel/framework`, `filament/filament`, `livewire/livewire`, `pestphp/pest`
|
||||||
|
**Storage**: PostgreSQL with existing `operation_runs` and `audit_logs` tables; no schema changes
|
||||||
|
**Testing**: Pest feature tests with Livewire component assertions, executed through Laravel Sail
|
||||||
|
**Target Platform**: Laravel web application running in Sail locally and containerized Linux environments in staging/production
|
||||||
|
**Project Type**: Laravel monolith with Filament panels
|
||||||
|
**Performance Goals**: Preserve current DB-only list rendering, eager loading of `tenant` and `workspace`, and existing pagination/sort behavior with no extra remote calls
|
||||||
|
**Constraints**: Platform plane only; no new pages/capabilities/persistence; visible naming must align to `Operations` / `Operation`; destructive triage stays confirmation-gated; existing queued-toast and audit behavior must remain intact
|
||||||
|
**Scale/Scope**: Four existing system-panel pages, one existing detail Blade view, one existing triage service, and the focused feature/guard tests that encode current row-action behavior
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
- `PASS` Inventory-first / snapshots-second: the feature does not change inventory, backups, or snapshot truth.
|
||||||
|
- `PASS` Read/write separation: no new writes are introduced; existing retry/cancel/mark-investigated flows remain explicit operator actions with confirmation where required.
|
||||||
|
- `PASS` Graph contract path: no Microsoft Graph call path is touched.
|
||||||
|
- `PASS` Deterministic capabilities: existing `PlatformCapabilities::OPERATIONS_VIEW` and `PlatformCapabilities::OPERATIONS_MANAGE` remain the canonical gates.
|
||||||
|
- `PASS` RBAC-UX plane separation: the slice is limited to `/system` surfaces and existing platform guard semantics.
|
||||||
|
- `PASS` Destructive confirmation standard: `cancel` remains `->requiresConfirmation()` on the detail header.
|
||||||
|
- `PASS` Ops-UX 3-surface feedback: retry continues to use `OperationUxPresenter::queuedToast(...)`; cancel and mark-investigated remain local terminal confirmations without adding queued/running notifications.
|
||||||
|
- `PASS` Ops lifecycle ownership: status/outcome mutation continues to flow through `OperationRunService` inside `OperationRunTriageService`; no new lifecycle path is introduced.
|
||||||
|
- `PASS` Proportionality / bloat: the feature removes duplicated interaction semantics and adds no new persistence, abstraction, state family, or taxonomy.
|
||||||
|
- `PASS` Badge semantics: list/detail badge rendering stays on the shared badge catalog and renderer.
|
||||||
|
- `PASS` Filament-native UI: the change stays inside existing Filament pages, row navigation, header actions, and confirmations.
|
||||||
|
- `PASS` UI surface taxonomy: Runs, Failures, and Stuck remain `ReadOnlyRegistryReport`; `ViewRun` remains the detail-first operational surface.
|
||||||
|
- `PASS` UI inspect model: each list will expose exactly one primary inspect model, `recordUrl()` row click.
|
||||||
|
- `PASS` UI action hierarchy: registry lists will have no inline triage; detail header remains the single triage owner.
|
||||||
|
- `PASS` UI naming scope: the changed system surfaces standardize visible copy to `Operations` / `Operation` while keeping internal route stability explicit.
|
||||||
|
- `PASS` Empty-state CTA rule: each changed list surface will define exactly one page-appropriate CTA and matching header action.
|
||||||
|
- `PASS` Placeholder/empty-group ban: no empty groups are introduced; bulk actions remain absent because there is no real bulk use case.
|
||||||
|
- `PASS` OPSURF page contract: the governing spec already defines persona, operator question, default truth, mutation scope, and dangerous actions.
|
||||||
|
- `PASS` Testing truth: implementation will update behavior guards, not just declarations.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/170-system-operations-surface-alignment/
|
||||||
|
├── plan.md
|
||||||
|
├── research.md
|
||||||
|
├── data-model.md
|
||||||
|
├── quickstart.md
|
||||||
|
├── contracts/
|
||||||
|
│ └── system-ops-surface-contract.yaml
|
||||||
|
└── tasks.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/
|
||||||
|
├── Filament/
|
||||||
|
│ └── System/
|
||||||
|
│ └── Pages/
|
||||||
|
│ └── Ops/
|
||||||
|
│ ├── Runs.php
|
||||||
|
│ ├── Failures.php
|
||||||
|
│ ├── Stuck.php
|
||||||
|
│ └── ViewRun.php
|
||||||
|
├── Services/
|
||||||
|
│ └── SystemConsole/
|
||||||
|
│ └── OperationRunTriageService.php
|
||||||
|
├── resources/
|
||||||
|
│ └── views/
|
||||||
|
│ └── filament/
|
||||||
|
│ └── system/
|
||||||
|
│ └── pages/
|
||||||
|
│ └── ops/
|
||||||
|
│ └── view-run.blade.php
|
||||||
|
└── Support/
|
||||||
|
└── System/
|
||||||
|
└── SystemOperationRunLinks.php
|
||||||
|
|
||||||
|
tests/
|
||||||
|
└── Feature/
|
||||||
|
├── Guards/
|
||||||
|
│ └── ActionSurfaceContractTest.php
|
||||||
|
└── System/
|
||||||
|
└── Spec114/
|
||||||
|
├── OpsTriageActionsTest.php
|
||||||
|
├── OpsFailuresViewTest.php
|
||||||
|
└── OpsStuckViewTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: This is a single Laravel application. The implementation is confined to existing system-panel Filament pages and existing Pest feature/guard tests. No new application layer or directory is needed.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
No constitution waiver is expected. This slice reduces surface complexity instead of introducing a new layer.
|
||||||
|
|
||||||
|
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||||
|
|-----------|------------|-------------------------------------|
|
||||||
|
| None | Not applicable | Not applicable |
|
||||||
|
|
||||||
|
## Proportionality Review
|
||||||
|
|
||||||
|
- **Current operator problem**: the three system list pages are declared as read-only registry surfaces but still expose direct triage actions, leave empty-state navigation underdefined, and continue to present `Runs` as a competing visible noun beside the canonical `Operations` vocabulary.
|
||||||
|
- **Existing structure is insufficient because**: the current hybrid model forces operators to choose between acting in-row and opening the richer detail page, while the constitution requires one primary inspect model, stable nouns, and explicit CTA behavior for changed list surfaces.
|
||||||
|
- **Narrowest correct implementation**: remove list row triage actions, keep `recordUrl()` row-click navigation, standardize visible copy to `Operations` / `Operation`, add one clear list CTA per changed surface, add a `Show all operations` return path on the detail page, and update the existing tests to assert the aligned behavior.
|
||||||
|
- **Ownership cost created**: low. The change requires focused updates to Filament page classes and the behavior guards that currently encode list-level triage.
|
||||||
|
- **Alternative intentionally rejected**: reclassifying the list pages as queue/review surfaces or adding new exception types was rejected because these pages are scan-first registries, not context-preserving decision queues.
|
||||||
|
- **Release truth**: current-release truth. The canonical detail page and triage service already exist today; the feature removes duplication rather than preparing speculative future structure.
|
||||||
|
|
||||||
|
## Post-Design Constitution Re-check
|
||||||
|
|
||||||
|
- `PASS` `UI-CONST-001` / `UI-SURF-001`: the artifacts keep list pages in the registry class and the detail page as the triage owner.
|
||||||
|
- `PASS` `UI-HARD-001`: the target design leaves each system list with exactly one primary inspect model and zero inline destructive actions.
|
||||||
|
- `PASS` `UI-EX-001`: no new exception type or exemption is needed for this slice.
|
||||||
|
- `PASS` `OPSURF-001`: list pages stay scan-first, while the detail page remains the context-rich operational surface.
|
||||||
|
- `PASS` `RBAC-UX-001` to `RBAC-UX-005`: view/manage separation, server-side enforcement, and destructive confirmation rules remain unchanged.
|
||||||
|
- `PASS` `UI-NAMING-001`: visible system-surface nomenclature now aligns to `Operations` / `Operation`.
|
||||||
|
- `PASS` Empty-state CTA requirement: the design now defines a primary CTA for each changed list surface and a return path on the detail page.
|
||||||
|
- `PASS` `BLOAT-001`: no new persistence, abstraction, state family, or taxonomy was introduced during design.
|
||||||
|
- `PASS` Filament v5 / Livewire v4 guardrails: the plan keeps changes inside existing Filament pages and does not require provider or asset registration changes.
|
||||||
73
specs/170-system-operations-surface-alignment/quickstart.md
Normal file
73
specs/170-system-operations-surface-alignment/quickstart.md
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
# Quickstart: System Operations Surface Alignment
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Bring the system-panel Operations surfaces into conformance with the declared read-only registry contract by removing inline triage from the list rows, standardizing visible naming to `Operations` / `Operation`, adding explicit list CTAs, and keeping triage on the canonical operation detail page.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. Start the local stack:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Work on branch `170-system-operations-surface-alignment`.
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
|
||||||
|
1. Update the three list pages:
|
||||||
|
- `app/Filament/System/Pages/Ops/Runs.php`
|
||||||
|
- `app/Filament/System/Pages/Ops/Failures.php`
|
||||||
|
- `app/Filament/System/Pages/Ops/Stuck.php`
|
||||||
|
2. Remove the `retry`, `cancel`, and `mark_investigated` table actions from each list page.
|
||||||
|
3. Keep `recordUrl()` row-click navigation intact for each list page.
|
||||||
|
4. Update visible navigation labels, headings, empty-state copy, and page CTA behavior so the changed surfaces use `Operations` / `Operation` vocabulary.
|
||||||
|
5. Update each list page's `actionSurfaceDeclaration()` explanation text so it reflects detail-owned triage rather than row-level triage.
|
||||||
|
6. Update `app/Filament/System/Pages/Ops/ViewRun.php` and `resources/views/filament/system/pages/ops/view-run.blade.php` so the detail page keeps triage ownership, uses `Operation #...` copy, and exposes `Show all operations`.
|
||||||
|
7. Leave `app/Services/SystemConsole/OperationRunTriageService.php` unchanged unless a small refactor is needed to support the page move without changing behavior.
|
||||||
|
|
||||||
|
## Tests To Update
|
||||||
|
|
||||||
|
1. `tests/Feature/System/Spec114/OpsTriageActionsTest.php`
|
||||||
|
- Replace row-action execution assertions on `Runs` with detail-page action visibility/execution assertions on `ViewRun`.
|
||||||
|
- Keep view-only versus manage-capable separation explicit.
|
||||||
|
- Add assertions for visible `Operations` / `Operation` copy and the `Show all operations` return path.
|
||||||
|
2. `tests/Feature/Guards/ActionSurfaceContractTest.php`
|
||||||
|
- Replace the three expectations for direct row triage with expectations that the row action set is empty while `recordUrl()` still points to `SystemOperationRunLinks::view($run)`.
|
||||||
|
- Assert the list CTA behavior and canonical Operations naming on the changed surfaces.
|
||||||
|
3. Keep the existing page-access tests for failures/stuck passing.
|
||||||
|
|
||||||
|
## Focused Verification
|
||||||
|
|
||||||
|
Run the smallest relevant test set first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/System/Spec114/OpsTriageActionsTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceContractTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
If implementation touches other platform-ops behavior, add the page access tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/System/Spec114/OpsFailuresViewTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/System/Spec114/OpsStuckViewTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Formatting
|
||||||
|
|
||||||
|
After code changes, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail bin pint --dirty --format agent
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manual Review Checklist
|
||||||
|
|
||||||
|
1. The `Runs`, `Failures`, and `Stuck` tables still navigate by row click.
|
||||||
|
2. None of the three lists exposes inline triage actions.
|
||||||
|
3. The changed system surfaces use `Operations` / `Operation` as the visible noun.
|
||||||
|
4. The Operations list exposes `Go to runbooks`, while Failed operations and Stuck operations expose `Show all operations` as the single header and empty-state CTA.
|
||||||
|
5. The system operation detail page still shows `Retry`, `Cancel`, and `Mark investigated` only for manage-capable operators and exposes `Show all operations` as the return path.
|
||||||
|
6. `Cancel` still requires confirmation.
|
||||||
|
7. Retry still produces the existing queued toast with a `View run` link.
|
||||||
43
specs/170-system-operations-surface-alignment/research.md
Normal file
43
specs/170-system-operations-surface-alignment/research.md
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
# Research: System Operations Surface Alignment
|
||||||
|
|
||||||
|
## Decision 1: Keep the three system list pages as read-only registry surfaces
|
||||||
|
|
||||||
|
- **Decision**: `Runs`, `Failures`, and `Stuck` stay classified as `ReadOnlyRegistryReport` surfaces with `recordUrl()` full-row navigation as the only primary inspect model.
|
||||||
|
- **Rationale**: The pages already declare `ActionSurfaceType::ReadOnlyRegistryReport`, already use clickable rows, and the constitution requires exactly one primary inspect/open model for registry surfaces. Inline triage on these lists is the specific behavior drift the spec is correcting.
|
||||||
|
- **Alternatives considered**: Reclassify the lists as queue/review surfaces. Rejected because the pages are scan-first status registers split by run state, not in-place decision queues.
|
||||||
|
|
||||||
|
## Decision 2: Keep triage ownership on the existing `ViewRun` page
|
||||||
|
|
||||||
|
- **Decision**: `Retry`, `Cancel`, and `Mark investigated` remain on `app/Filament/System/Pages/Ops/ViewRun.php` and are removed from the list rows.
|
||||||
|
- **Rationale**: The detail page already exposes all three actions, already loads `tenant` and `workspace`, and already provides the surrounding operational context the operator needs before acting. Repo analysis of the operation-run detail surface confirms that the page is already the rich context owner for run truth and next steps.
|
||||||
|
- **Alternatives considered**: Add a new dedicated triage modal or keep one “safe” triage action inline on the lists. Rejected because both options preserve split ownership and weaken the single-entry operational model.
|
||||||
|
|
||||||
|
## Decision 3: Reuse the existing triage service and links without abstraction changes
|
||||||
|
|
||||||
|
- **Decision**: `OperationRunTriageService` and `SystemOperationRunLinks` remain the canonical implementation path for retry/cancel/investigate and detail navigation.
|
||||||
|
- **Rationale**: The service already encapsulates retryability/cancelability rules, audit logging, `OperationRunService` lifecycle transitions, and triage context writes. This spec changes action placement, not domain behavior.
|
||||||
|
- **Alternatives considered**: Introduce a new presenter, coordinator, or list-specific triage wrapper. Rejected under `ABSTR-001` and `LAYER-001` because there is only one existing triage behavior and the current service already fits it.
|
||||||
|
|
||||||
|
## Decision 4: Move behavior guards from “direct row triage” to “detail-owned triage”
|
||||||
|
|
||||||
|
- **Decision**: Update `tests/Feature/Guards/ActionSurfaceContractTest.php` and `tests/Feature/System/Spec114/OpsTriageActionsTest.php` so they assert row-click-only lists and detail-header triage ownership.
|
||||||
|
- **Rationale**: The constitution explicitly prioritizes rendered behavior over declaration. The current guard suite still encodes the old hybrid interaction model by expecting `retry`, `cancel`, and `mark_investigated` on all three list pages.
|
||||||
|
- **Alternatives considered**: Leave the existing guards untouched and only rely on declarations, or delete the guards. Rejected because that would preserve declaration-only conformance and remove the regression protection this slice is supposed to strengthen.
|
||||||
|
|
||||||
|
## Decision 5: Absorb visible Operations naming into Spec 170 while keeping route stability explicit
|
||||||
|
|
||||||
|
- **Decision**: The changed system surfaces standardize their visible collection and singular nouns to `Operations` and `Operation` in navigation labels, headings, empty-state copy, and return links, while existing internal class names and `/system/ops/runs` route paths may remain stable for compatibility.
|
||||||
|
- **Rationale**: The constitution treats `Operations` as the canonical collection noun for run records. Leaving the changed system surfaces on `Runs` would keep the spec itself in conflict with the constitution.
|
||||||
|
- **Alternatives considered**: Defer naming to Spec 171. Rejected because it leaves a known constitution conflict inside the active slice.
|
||||||
|
|
||||||
|
## Decision 6: Give every changed list surface one explicit CTA and the detail page one explicit return path
|
||||||
|
|
||||||
|
- **Decision**: The Operations list uses `Go to runbooks` as its single header and empty-state CTA. Failed operations and Stuck operations use `Show all operations` as their single header and empty-state CTA. The detail page exposes `Show all operations` as the canonical return path and keeps `Go to runbooks` as secondary navigation.
|
||||||
|
- **Rationale**: The constitution requires changed list surfaces to define explicit empty-state CTA behavior and requires a clear canonical navigation model. These CTAs stay navigation-only and do not reintroduce inline row clutter.
|
||||||
|
- **Alternatives considered**: Keep explanation-only empty states or add multiple list CTAs. Rejected because the first violates the constitution and the second weakens scanability.
|
||||||
|
|
||||||
|
## Decision 7: No new API surface; document the route/UI contract explicitly
|
||||||
|
|
||||||
|
- **Decision**: Capture the expected route and interaction contract in a small OpenAPI-style YAML file under `contracts/` even though the feature does not add a public JSON API.
|
||||||
|
- **Rationale**: The implementation change is route-driven and operator-visible. A route contract that records surface type, inspect affordance, action placement, and capability boundaries gives later tasks and reviews a concrete artifact without inventing a new backend API.
|
||||||
|
- **Alternatives considered**: Skip `contracts/` entirely. Rejected because the planning workflow expects a concrete contract artifact, and this feature benefits from a machine-readable record of its operator-visible contract.
|
||||||
206
specs/170-system-operations-surface-alignment/spec.md
Normal file
206
specs/170-system-operations-surface-alignment/spec.md
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
# Feature Specification: System Operations Surface Alignment
|
||||||
|
|
||||||
|
**Feature Branch**: `170-system-operations-surface-alignment`
|
||||||
|
**Created**: 2026-03-30
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "System Operations Surface Alignment"
|
||||||
|
|
||||||
|
## Spec Scope Fields *(mandatory)*
|
||||||
|
|
||||||
|
- **Scope**: platform
|
||||||
|
- **Primary Routes**:
|
||||||
|
- `/system/ops/runs`
|
||||||
|
- `/system/ops/failures`
|
||||||
|
- `/system/ops/stuck`
|
||||||
|
- `/system/ops/runs/{run}`
|
||||||
|
- **Data Ownership**:
|
||||||
|
- No new platform-owned, workspace-owned, or tenant-owned records are introduced
|
||||||
|
- Existing `OperationRun` records remain the only source of truth for system operations list and detail surfaces
|
||||||
|
- This feature changes only operator-facing interaction semantics for existing system operations pages
|
||||||
|
- **RBAC**:
|
||||||
|
- Platform plane only
|
||||||
|
- Existing `platform.operations.view` and `platform.operations.manage` capability boundaries remain authoritative
|
||||||
|
- Users without system access remain deny-as-not-found by existing platform routing and auth guards
|
||||||
|
- Users with view capability but without manage capability remain able to inspect runs but unable to execute triage actions
|
||||||
|
|
||||||
|
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Surface Type | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| System operations list | Read-only Registry / Report | Full-row click to system operation detail | required | header CTA only | none on the list | `/system/ops/runs` | `/system/ops/runs/{run}` | Platform scope only | Operations / Operation | status, outcome, operation type, workspace, tenant, recency | none |
|
||||||
|
| System failed operations list | Read-only Registry / Report | Full-row click to system operation detail | required | header CTA only | none on the list | `/system/ops/failures` | `/system/ops/runs/{run}` | Platform scope only | Operations / Operation | failed outcome, operation type, workspace, tenant, recency | none |
|
||||||
|
| System stuck operations list | Read-only Registry / Report | Full-row click to system operation detail | required | header CTA only | none on the list | `/system/ops/stuck` | `/system/ops/runs/{run}` | Platform scope only | Operations / Operation | queued or running stale state, operation type, workspace, tenant, recency | none |
|
||||||
|
| System operation detail | Detail-first Operational Surface | Dedicated detail page | forbidden | detail header groups only | detail header only | `/system/ops/runs` | `/system/ops/runs/{run}` | Platform scope only | Operations / Operation | operation truth, failure cause, context, related navigation, next actions | none |
|
||||||
|
|
||||||
|
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| System operations list | Platform operator | Read-only Registry / Report | Which operation should I open next? | status, outcome, operation label, workspace, tenant, initiator, activity time | raw payloads and deeper traces stay on detail | execution outcome, recency | Read-only list | Open operation, go to runbooks | none |
|
||||||
|
| System failed operations list | Platform operator | Read-only Registry / Report | Which failed operation needs investigation first? | failed outcome, operation label, workspace, tenant, activity time | raw payloads and deeper traces stay on detail | execution outcome, failure state, recency | Read-only list | Open operation, show all operations | none |
|
||||||
|
| System stuck operations list | Platform operator | Read-only Registry / Report | Which queued or running operation has crossed the stuck threshold? | stuck class, operation label, workspace, tenant, activity time | raw payloads and deeper traces stay on detail | lifecycle stall state, recency | Read-only list | Open operation, show all operations | none |
|
||||||
|
| System operation detail | Platform operator | Detail-first Operational Surface | What happened on this operation, and what follow-up is appropriate? | operation identity, status, outcome, related scope, dominant failure or stall context, related links | low-level payloads, internal traces, and extended diagnostics | execution outcome, lifecycle state, operability context | Existing platform triage only | Show all operations, go to runbooks, retry operation, mark investigated | Cancel operation when still cancellable |
|
||||||
|
|
||||||
|
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||||
|
|
||||||
|
- **New source of truth?**: No
|
||||||
|
- **New persisted entity/table/artifact?**: No
|
||||||
|
- **New abstraction?**: No
|
||||||
|
- **New enum/state/reason family?**: No
|
||||||
|
- **New cross-domain UI framework/taxonomy?**: No
|
||||||
|
- **Current operator problem**: The three system operations list pages currently behave like scan-first registry surfaces but also expose direct triage actions, underdefined empty states, and competing Operations versus Runs naming that duplicate or dilute the canonical detail model.
|
||||||
|
- **Existing structure is insufficient because**: The current list surfaces split triage ownership between list and detail, which makes the lists behave like mini control centers instead of scan-first registries.
|
||||||
|
- **Narrowest correct implementation**: Keep the existing system lists and the existing system operation detail page, but move triage ownership fully onto the detail page, align visible naming to Operations / Operation, and give each list one clear navigation CTA without changing persistence or introducing new surfaces.
|
||||||
|
- **Ownership cost**: Existing list and guard tests need to be updated, and operators lose direct row-level triage from the lists in exchange for one consistent detail-first follow-up model.
|
||||||
|
- **Alternative intentionally rejected**: Reclassifying the system lists into a queue or review surface was rejected because the pages are scan-first registries split by status family, not context-preserving queue workflows.
|
||||||
|
- **Release truth**: Current-release truth. The repo already contains the canonical detail page and the duplicated list triage actions; this slice removes the duplication rather than adding new capability.
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Scan Lists Without Competing Actions (Priority: P1)
|
||||||
|
|
||||||
|
As a platform operator, I want the Operations, Failed operations, and Stuck operations lists to behave as scan-first registries with one obvious open path and one clear navigation CTA, so that I can inspect the right operation without row-level action clutter.
|
||||||
|
|
||||||
|
**Why this priority**: This is the direct constitution violation on the current system operations surfaces.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by loading each system operations list and asserting that rows remain clickable, row-level triage actions are absent, visible labels use Operations / Operation vocabulary, and each list exposes the expected primary CTA in header and empty state.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a platform operator opens the system Operations list, **When** the table renders, **Then** each row opens the system operation detail page through row click, exposes no row-level triage actions, and keeps `Go to runbooks` as the single header and empty-state CTA.
|
||||||
|
2. **Given** a platform operator opens the system Failed operations list, **When** the table renders, **Then** the row remains clickable, the list does not expose retry, cancel, or investigate actions inline, and `Show all operations` is the single header and empty-state CTA.
|
||||||
|
3. **Given** a platform operator opens the system Stuck operations list, **When** the table renders, **Then** the row remains clickable, the list does not expose retry, cancel, or investigate actions inline, and `Show all operations` is the single header and empty-state CTA.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Perform Triage From The Canonical Detail Page (Priority: P1)
|
||||||
|
|
||||||
|
As a platform operator with manage capability, I want system operation triage to live on the canonical operation detail page, so that every follow-up action happens in the surface that already owns full context and keeps a clear return path to all operations.
|
||||||
|
|
||||||
|
**Why this priority**: The lists can only become constitution-compliant if the detail page becomes the single triage destination.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by opening a system operation detail page as a manage-capable operator and asserting that retry, cancel, and mark investigated remain available there with the same audit and queued-run behavior, while `Show all operations` remains available as the return path.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a failed operation and a manage-capable platform operator, **When** the operator opens the system operation detail page, **Then** retry remains available on the detail header and still queues a replacement operation.
|
||||||
|
2. **Given** a cancellable operation and a manage-capable platform operator, **When** the operator opens the system operation detail page, **Then** cancel remains available on the detail header and still requires confirmation.
|
||||||
|
3. **Given** an operation that needs documentation, **When** the operator opens the system operation detail page, **Then** mark investigated remains available there, still records the investigation action, and the page keeps `Show all operations` as the canonical return link.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Preserve View-Only Access Semantics (Priority: P2)
|
||||||
|
|
||||||
|
As a platform operator with view-only access, I want to inspect system operations without being offered triage controls, so that the platform plane stays capability-correct while the aligned surfaces remain usable.
|
||||||
|
|
||||||
|
**Why this priority**: Surface alignment must not weaken the existing view/manage separation.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by rendering list and detail surfaces for a view-only system user and asserting that inspection and navigation remain available while triage actions remain hidden.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a platform user with operations view but not operations manage, **When** the user opens any system operations list, **Then** the user can inspect rows, use the page CTA, but sees no triage actions there.
|
||||||
|
2. **Given** a platform user with operations view but not operations manage, **When** the user opens a system operation detail page, **Then** retry, cancel, and mark investigated remain hidden while `Show all operations` and `Go to runbooks` remain available.
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- A failed operation appears on both the all-operations list and the failed-operations list; both surfaces must expose the same single open model and must not diverge in row actions or naming.
|
||||||
|
- A queued or running operation later becomes cancellable or non-cancellable; the list remains read-only while the detail page resolves whether cancel is available.
|
||||||
|
- The system operation detail page must keep a clear return path to the canonical Operations list even when opened from Failed operations or Stuck operations.
|
||||||
|
- Empty Operations, Failed operations, and Stuck operations states must remain explanation-first while still exposing exactly one primary CTA that matches the page contract.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
**Constitution alignment (required):** This feature introduces no Microsoft Graph calls, no new write workflow, and no new long-running work. Existing `OperationRun` and triage services remain the underlying execution model. The feature only realigns where platform operators inspect and triage existing system runs.
|
||||||
|
|
||||||
|
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature adds no new structure, persistence, abstraction, or state family. It reduces surface complexity by removing duplicated triage ownership from the lists.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-UX):** Existing retry and cancel flows continue to reuse the current queued-run UX, terminal notification rules, and service-owned `OperationRun` lifecycle. This feature does not introduce a new run type or change lifecycle ownership.
|
||||||
|
|
||||||
|
**Constitution alignment (RBAC-UX):** This feature stays in the platform plane only. Existing `OPERATIONS_VIEW` and `OPERATIONS_MANAGE` capability checks remain the server-side source of truth. List alignment must not weaken the current distinction between view-only inspection and manage-capable triage.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable.
|
||||||
|
|
||||||
|
**Constitution alignment (BADGE-001):** Existing status and outcome badge semantics remain unchanged and centralized.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-FIL-001):** The feature continues to use Filament-native tables, row navigation, header actions, confirmation modals, and notifications. No custom local action framework or styling language is introduced.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-NAMING-001):** This slice standardizes the changed system-plane surfaces to the canonical visible nouns `Operations` and `Operation`. Existing internal PHP class names and route paths may remain stable, but operator-facing labels, headings, and return links MUST stop presenting `Runs` as the primary noun.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001):** The system Runs, Failures, and Stuck pages MUST align to the Read-only Registry / Report surface rules: one-click row open, no competing inline triage controls, and no destructive actions on the list rows. The system run detail page MUST remain the sole triage surface for retry, cancel, and mark investigated.
|
||||||
|
|
||||||
|
**Constitution alignment (OPSURF-001):** The lists remain operator-first scan surfaces that show only the truth needed to choose the next run to inspect. Full follow-up context and triage remain on the detail page, where diagnostic depth already exists.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** This feature does not add a new semantic layer. It removes duplicated action ownership and keeps tests focused on operator-visible behavior: list inspect model, detail triage ownership, and manage-vs-view capability behavior.
|
||||||
|
|
||||||
|
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract is satisfied when Operations, Failed operations, and Stuck operations expose row click only, keep bulk actions absent by explicit no-bulk need, provide one header and empty-state CTA each, and leave retry, cancel, and mark investigated to the detail header. No new exemption is introduced.
|
||||||
|
|
||||||
|
**Constitution alignment (UX-001 — Layout & Information Architecture):** List layouts remain scanable registry tables. The system run detail page remains the operational detail surface and continues to own richer follow-up actions. No create or edit layout changes are introduced.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-170-001**: The system Operations list MUST behave as a read-only registry surface with one primary inspect model: full-row click to the canonical system operation detail page.
|
||||||
|
- **FR-170-002**: The system Failed operations list MUST behave as a read-only registry surface with one primary inspect model: full-row click to the canonical system operation detail page.
|
||||||
|
- **FR-170-003**: The system Stuck operations list MUST behave as a read-only registry surface with one primary inspect model: full-row click to the canonical system operation detail page.
|
||||||
|
- **FR-170-004**: the Operations, Failed operations, and Stuck operations lists MUST NOT render retry, cancel, or mark investigated as row actions.
|
||||||
|
- **FR-170-005**: The canonical system operation detail page MUST remain the only system-plane surface that exposes retry, cancel, and mark investigated actions.
|
||||||
|
- **FR-170-006**: Retry on the system operation detail page MUST preserve the current queued-run feedback behavior and the current link back to the newly queued operation.
|
||||||
|
- **FR-170-007**: Cancel on the system operation detail page MUST remain confirmation-gated and MUST stay available only when the current operation is still cancellable.
|
||||||
|
- **FR-170-008**: Mark investigated on the system operation detail page MUST remain confirmation-gated and MUST continue to require an operator-supplied reason.
|
||||||
|
- **FR-170-009**: View-only platform operators MUST remain able to open list rows and detail pages while triage actions remain hidden.
|
||||||
|
- **FR-170-010**: Manage-capable platform operators MUST retain triage capability on the system operation detail page after list row actions are removed.
|
||||||
|
- **FR-170-011**: Existing audit behavior for retry and mark investigated MUST remain unchanged.
|
||||||
|
- **FR-170-012**: The system operation detail page MUST provide a clear `Show all operations` return path to the canonical collection route while preserving `Go to runbooks` navigation.
|
||||||
|
- **FR-170-013**: This feature MUST NOT introduce a new page, a new system operations capability, or a new persisted artifact.
|
||||||
|
- **FR-170-014**: Repository guard tests for the system operations surfaces MUST be updated so they assert row-click-only lists, list CTAs, canonical Operations / Operation naming, and detail-owned triage instead of direct row triage.
|
||||||
|
- **FR-170-015**: The changed system surfaces MUST use `Operations` as the canonical visible collection noun and `Operation` as the canonical visible singular noun.
|
||||||
|
- **FR-170-016**: Each changed system list surface MUST expose exactly one primary empty-state CTA and the corresponding header action when records exist.
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| System operations list | `app/Filament/System/Pages/Ops/Runs.php` | `Go to runbooks` | `recordUrl()` full-row click | none | none | `Go to runbooks` | n/a | n/a | no new audit behavior | Read-only Registry / Report; visible label becomes `Operations` |
|
||||||
|
| System failed operations list | `app/Filament/System/Pages/Ops/Failures.php` | `Show all operations` | `recordUrl()` full-row click | none | none | `Show all operations` | n/a | n/a | no new audit behavior | Read-only Registry / Report; visible label becomes `Failed operations` |
|
||||||
|
| System stuck operations list | `app/Filament/System/Pages/Ops/Stuck.php` | `Show all operations` | `recordUrl()` full-row click | none | none | `Show all operations` | n/a | n/a | no new audit behavior | Read-only Registry / Report; visible label becomes `Stuck operations` |
|
||||||
|
| System operation detail | `app/Filament/System/Pages/Ops/ViewRun.php`, `resources/views/filament/system/pages/ops/view-run.blade.php` | `Show all operations`, `Go to runbooks` | n/a | n/a | n/a | n/a | `Retry`, `Cancel`, `Mark investigated` | n/a | existing retry and mark-investigated audit behavior remains | Detail-first operational owner of triage; heading and return link use `Operation` |
|
||||||
|
|
||||||
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **System operations list surface**: The system-panel Operations, Failed operations, and Stuck operations pages that scan existing `OperationRun` records.
|
||||||
|
- **System operation detail surface**: The canonical system detail page for one selected `OperationRun`.
|
||||||
|
- **System operation triage action**: Existing follow-up actions for retry, cancel, and mark investigated.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
- **SC-170-001**: Operations, Failed operations, and Stuck operations each expose exactly one primary inspect model in automated coverage: row click to the canonical system operation detail page.
|
||||||
|
- **SC-170-002**: Automated coverage verifies that Operations, Failed operations, and Stuck operations expose zero row-level triage actions.
|
||||||
|
- **SC-170-003**: Automated coverage verifies that the system operation detail page still exposes retry, cancel, and mark investigated for manage-capable operators when each action is legitimately available.
|
||||||
|
- **SC-170-004**: Automated coverage verifies that view-only platform operators can inspect system operations but cannot see manage-only triage actions on the detail page.
|
||||||
|
- **SC-170-005**: Automated coverage verifies that the changed system surfaces use the canonical visible nouns `Operations` and `Operation` and expose the expected header or empty-state CTA on each list surface.
|
||||||
|
- **SC-170-006**: The feature ships without adding any new capability, persistence, or UI exemption.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- The current system run detail page remains the correct canonical place for triage ownership.
|
||||||
|
- Existing audit behavior for retry and mark investigated is correct and does not need redesign in this slice.
|
||||||
|
- Existing internal route paths under `/system/ops/runs` may remain stable while visible system-surface naming is standardized in this slice.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Renaming internal PHP class names or changing existing `/system/ops/runs` route paths
|
||||||
|
- Retrofitting deferred dashboard, onboarding, or landing surfaces
|
||||||
|
- Changing `OperationRun` lifecycle semantics, run creation behavior, or notification taxonomy
|
||||||
|
- Introducing a queue/review model for the system Operations, Failed operations, or Stuck operations pages
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Existing system operations list pages: Runs, Failures, and Stuck
|
||||||
|
- Existing system run detail page
|
||||||
|
- Existing `OperationRunTriageService`
|
||||||
|
- Existing platform capability and audit behavior
|
||||||
|
- Existing action-surface and system operations guard coverage
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
Spec 170 is complete when the three changed system list pages are scan-first row-click-only registry surfaces, visible system naming uses Operations / Operation, each list has one matching header and empty-state CTA, the system operation detail page is the sole owner of retry/cancel/investigate triage and exposes `Show all operations`, existing view/manage capability semantics remain intact, and guard tests reflect the aligned interaction model.
|
||||||
199
specs/170-system-operations-surface-alignment/tasks.md
Normal file
199
specs/170-system-operations-surface-alignment/tasks.md
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
# Tasks: System Operations Surface Alignment
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/170-system-operations-surface-alignment/`
|
||||||
|
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, quickstart.md, contracts/system-ops-surface-contract.yaml
|
||||||
|
|
||||||
|
**Tests**: Runtime behavior changes in this repo require Pest coverage. Each story below includes test work and focused verification.
|
||||||
|
|
||||||
|
**Organization**: Tasks are grouped by user story so each story can be implemented and validated independently where the current surface boundaries allow it.
|
||||||
|
|
||||||
|
## Phase 1: Setup
|
||||||
|
|
||||||
|
**Purpose**: Lock the implementation targets to the generated plan artifacts before runtime edits begin.
|
||||||
|
|
||||||
|
- [X] T001 Reconfirm the implementation and verification targets in `specs/170-system-operations-surface-alignment/contracts/system-ops-surface-contract.yaml` and `specs/170-system-operations-surface-alignment/quickstart.md` before editing runtime files.
|
||||||
|
- [X] T002 Inspect the current system ops touchpoints in `app/Filament/System/Pages/Ops/Runs.php`, `app/Filament/System/Pages/Ops/Failures.php`, `app/Filament/System/Pages/Ops/Stuck.php`, `app/Filament/System/Pages/Ops/ViewRun.php`, `tests/Feature/System/Spec114/OpsTriageActionsTest.php`, and `tests/Feature/Guards/ActionSurfaceContractTest.php` so every direct row-triage assertion is mapped to the aligned target behavior.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational
|
||||||
|
|
||||||
|
**Purpose**: Establish the shared regression baseline that all story work must satisfy.
|
||||||
|
|
||||||
|
**⚠️ CRITICAL**: No user story work should be considered complete until these shared assertions are updated.
|
||||||
|
|
||||||
|
- [X] T003 Update the shared system surface regression contract in `tests/Feature/Guards/ActionSurfaceContractTest.php` so the baseline expects clickable rows, canonical `Operations` naming, explicit list CTA behavior, and no row triage on the changed system pages.
|
||||||
|
- [X] T004 Update the shared system triage and navigation scenario coverage in `tests/Feature/System/Spec114/OpsTriageActionsTest.php` so list-surface, detail-surface, view-only, and `Show all operations` assertions can be expressed separately.
|
||||||
|
|
||||||
|
**Checkpoint**: Shared contract coverage is ready; story implementation can now proceed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 - Scan Lists Without Competing Actions (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: Make the Operations, Failed operations, and Stuck operations pages behave as scan-first registry surfaces with row-click-only inspection, stable naming, and one clear CTA each.
|
||||||
|
|
||||||
|
**Independent Test**: Load each list page and verify `recordUrl()` row navigation remains intact, all row-level triage actions are absent, visible copy uses `Operations` / `Operation`, and the correct single CTA appears in both header and empty state.
|
||||||
|
|
||||||
|
### Tests for User Story 1
|
||||||
|
|
||||||
|
- [X] T005 [US1] Add failing list-behavior and CTA assertions in `tests/Feature/System/Spec114/OpsTriageActionsTest.php` that require row-click-only behavior, canonical `Operations` naming, and the correct single CTA on the changed system lists.
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [X] T006 [P] [US1] Remove `retry`, `cancel`, and `mark_investigated` row actions, rename the visible label to `Operations`, add the `Go to runbooks` header and empty-state CTA, and update the action-surface declaration wording in `app/Filament/System/Pages/Ops/Runs.php`.
|
||||||
|
- [X] T007 [P] [US1] Remove `retry`, `cancel`, and `mark_investigated` row actions, rename the visible label to `Failed operations`, add the `Show all operations` header and empty-state CTA, and update the action-surface declaration wording in `app/Filament/System/Pages/Ops/Failures.php`.
|
||||||
|
- [X] T008 [P] [US1] Remove `retry`, `cancel`, and `mark_investigated` row actions, rename the visible label to `Stuck operations`, add the `Show all operations` header and empty-state CTA, and update the action-surface declaration wording in `app/Filament/System/Pages/Ops/Stuck.php`.
|
||||||
|
- [X] T009 [US1] Verify the three list pages still use `recordUrl()` and expose the correct CTA behavior in `app/Filament/System/Pages/Ops/Runs.php`, `app/Filament/System/Pages/Ops/Failures.php`, and `app/Filament/System/Pages/Ops/Stuck.php`.
|
||||||
|
- [X] T010 [US1] Run the focused list-surface regression checks in `tests/Feature/Guards/ActionSurfaceContractTest.php` and `tests/Feature/System/Spec114/OpsTriageActionsTest.php`.
|
||||||
|
|
||||||
|
**Checkpoint**: The three system list pages are row-click-only registry surfaces and can be validated independently.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 - Perform Triage From The Canonical Detail Page (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: Keep the existing `ViewRun` page as the sole triage surface for retry, cancel, and mark investigated while exposing `Show all operations` as the canonical return path.
|
||||||
|
|
||||||
|
**Independent Test**: Open a system operation detail page as a manage-capable platform user and verify header-based retry, cancel, and mark-investigated behavior still works with the same queued-toast, confirmation, and audit expectations, while `Show all operations` and `Go to runbooks` remain available.
|
||||||
|
|
||||||
|
### Tests for User Story 2
|
||||||
|
|
||||||
|
- [X] T011 [US2] Add failing detail-page triage and return-path assertions in `tests/Feature/System/Spec114/OpsTriageActionsTest.php` for `Retry`, `Cancel`, `Mark investigated`, `Show all operations`, and visible `Operation` copy on `app/Filament/System/Pages/Ops/ViewRun.php`.
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [X] T012 [US2] Keep `app/Filament/System/Pages/Ops/ViewRun.php` as the sole triage owner and ensure header actions expose `Show all operations`, `Go to runbooks`, and the existing manage-only triage behavior through `app/Support/System/SystemOperationRunLinks.php`.
|
||||||
|
- [X] T013 [US2] Update the visible detail copy and return-link presentation in `resources/views/filament/system/pages/ops/view-run.blade.php` so the page uses `Operation #...` and shows `Show all operations` alongside `Go to runbooks`.
|
||||||
|
- [X] T014 [US2] Preserve existing retry, cancel, and investigation behavior without new lifecycle or audit changes in `app/Services/SystemConsole/OperationRunTriageService.php`.
|
||||||
|
- [X] T015 [US2] Run the focused detail-triage and return-path checks in `tests/Feature/System/Spec114/OpsTriageActionsTest.php`.
|
||||||
|
|
||||||
|
**Checkpoint**: Detail-page triage ownership is intact and independently verified.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 - Preserve View-Only Access Semantics (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Preserve the existing view/manage capability split so view-only operators can inspect and navigate but not triage.
|
||||||
|
|
||||||
|
**Independent Test**: Render the aligned list and detail surfaces for a view-only platform user and verify inspection and navigation remain available while triage actions stay hidden.
|
||||||
|
|
||||||
|
### Tests for User Story 3
|
||||||
|
|
||||||
|
- [X] T016 [US3] Add failing view-only authorization assertions in `tests/Feature/System/Spec114/OpsTriageActionsTest.php` covering list inspection, visible CTA navigation, and hidden detail-header triage.
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [X] T017 [US3] Keep manage-only detail action visibility intact in `app/Filament/System/Pages/Ops/ViewRun.php` while preserving view access and list CTA visibility in `app/Filament/System/Pages/Ops/Runs.php`, `app/Filament/System/Pages/Ops/Failures.php`, and `app/Filament/System/Pages/Ops/Stuck.php`.
|
||||||
|
- [X] T018 [US3] Reconfirm platform operations access expectations in `tests/Feature/System/Spec114/OpsFailuresViewTest.php` and `tests/Feature/System/Spec114/OpsStuckViewTest.php` after the surface alignment changes.
|
||||||
|
- [X] T019 [US3] Run the focused authorization checks in `tests/Feature/System/Spec114/OpsTriageActionsTest.php`, `tests/Feature/System/Spec114/OpsFailuresViewTest.php`, and `tests/Feature/System/Spec114/OpsStuckViewTest.php`.
|
||||||
|
|
||||||
|
**Checkpoint**: View-only operators can inspect aligned system surfaces without gaining triage controls.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Final cleanup and verification across all stories.
|
||||||
|
|
||||||
|
- [X] T020 Run formatting with `vendor/bin/sail bin pint --dirty --format agent` for `app/Filament/System/Pages/Ops/Runs.php`, `app/Filament/System/Pages/Ops/Failures.php`, `app/Filament/System/Pages/Ops/Stuck.php`, `app/Filament/System/Pages/Ops/ViewRun.php`, `resources/views/filament/system/pages/ops/view-run.blade.php`, `tests/Feature/System/Spec114/OpsTriageActionsTest.php`, and `tests/Feature/Guards/ActionSurfaceContractTest.php`.
|
||||||
|
- [X] T021 Run the full focused regression pack from `specs/170-system-operations-surface-alignment/quickstart.md` against `tests/Feature/Guards/ActionSurfaceContractTest.php`, `tests/Feature/System/Spec114/OpsTriageActionsTest.php`, `tests/Feature/System/Spec114/OpsFailuresViewTest.php`, and `tests/Feature/System/Spec114/OpsStuckViewTest.php`.
|
||||||
|
- [X] T022 Validate the final behavior against `specs/170-system-operations-surface-alignment/contracts/system-ops-surface-contract.yaml` and `specs/170-system-operations-surface-alignment/quickstart.md` before handoff.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Setup (Phase 1)**: No dependencies.
|
||||||
|
- **Foundational (Phase 2)**: Depends on Setup and establishes the shared regression baseline.
|
||||||
|
- **User Story 1 (Phase 3)**: Depends on Foundational.
|
||||||
|
- **User Story 2 (Phase 4)**: Depends on Foundational.
|
||||||
|
- **User Story 3 (Phase 5)**: Depends on User Story 1 and User Story 2 because it validates the final aligned list-plus-detail authorization and navigation behavior.
|
||||||
|
- **Polish (Phase 6)**: Depends on the desired user stories being complete.
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **US1**: Independent after Phase 2; it only changes list-surface semantics.
|
||||||
|
- **US2**: Independent after Phase 2; it preserves existing detail-surface triage ownership.
|
||||||
|
- **US3**: Validates the final permission split across the aligned list and detail surfaces, so it should run after US1 and US2 settle the interaction model.
|
||||||
|
|
||||||
|
### Within Each User Story
|
||||||
|
|
||||||
|
- Update story-specific tests first and make them fail for the intended new behavior.
|
||||||
|
- Apply source changes next.
|
||||||
|
- Run the smallest focused verification pack before moving on.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Opportunities
|
||||||
|
|
||||||
|
- T006, T007, and T008 can run in parallel because they modify different list-page files.
|
||||||
|
- US1 and US2 can proceed in parallel after Phase 2 if test-file coordination is handled deliberately.
|
||||||
|
- Polish verification can start as soon as the last required story is merged locally.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: User Story 1
|
||||||
|
|
||||||
|
```bash
|
||||||
|
Task: "T006 Remove row triage actions in app/Filament/System/Pages/Ops/Runs.php"
|
||||||
|
Task: "T007 Remove row triage actions in app/Filament/System/Pages/Ops/Failures.php"
|
||||||
|
Task: "T008 Remove row triage actions in app/Filament/System/Pages/Ops/Stuck.php"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: User Story 2
|
||||||
|
|
||||||
|
```bash
|
||||||
|
Task: "T011 Add detail-page triage and return-path assertions in tests/Feature/System/Spec114/OpsTriageActionsTest.php"
|
||||||
|
Task: "T013 Update visible detail copy in resources/views/filament/system/pages/ops/view-run.blade.php"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: User Story 3
|
||||||
|
|
||||||
|
```bash
|
||||||
|
Task: "T016 Add view-only authorization assertions in tests/Feature/System/Spec114/OpsTriageActionsTest.php"
|
||||||
|
Task: "T018 Reconfirm access expectations in tests/Feature/System/Spec114/OpsFailuresViewTest.php and tests/Feature/System/Spec114/OpsStuckViewTest.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 the row-click-only list behavior with the focused regression pack.
|
||||||
|
5. Demo the aligned registry surfaces before touching any further hardening.
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Finish Setup + Foundational to lock the shared regression baseline.
|
||||||
|
2. Deliver US1 to normalize the list surfaces.
|
||||||
|
3. Deliver US2 to prove detail-page triage remains the sole operational owner and preserves the canonical return path.
|
||||||
|
4. Deliver US3 to confirm the final view/manage capability split.
|
||||||
|
5. Run Phase 6 polish checks and hand off.
|
||||||
|
|
||||||
|
### Parallel Team Strategy
|
||||||
|
|
||||||
|
1. One engineer updates the shared regression baseline in Phase 2.
|
||||||
|
2. After Phase 2:
|
||||||
|
- Engineer A can take US1 list-page updates.
|
||||||
|
- Engineer B can take US2 detail-page preservation work.
|
||||||
|
3. Once US1 and US2 land locally, a final pass can execute US3 authorization hardening and the polish phase.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `[P]` tasks touch different files and have no unfinished dependencies.
|
||||||
|
- Story labels map directly to the three user stories in `spec.md`.
|
||||||
|
- This feature intentionally avoids route renames, capability changes, schema changes, and new UI exemptions.
|
||||||
|
- Keep `OperationRunTriageService` narrow unless a behavior-preserving tweak is genuinely required.
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
# Specification Quality Checklist: Operations Naming Consolidation
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-03-30
|
||||||
|
**Feature**: [spec.md](../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: complete
|
||||||
|
- This spec intentionally starts after Spec 170 and is limited to residual operator-visible naming drift outside the already-aligned system operations surfaces.
|
||||||
181
specs/171-operations-naming-consolidation/spec.md
Normal file
181
specs/171-operations-naming-consolidation/spec.md
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
# Feature Specification: Operations Naming Consolidation
|
||||||
|
|
||||||
|
**Feature Branch**: `171-operations-naming-consolidation`
|
||||||
|
**Created**: 2026-03-30
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Operations Naming Consolidation"
|
||||||
|
|
||||||
|
## Spec Scope Fields *(mandatory)*
|
||||||
|
|
||||||
|
- **Scope**: workspace + tenant + canonical-view + platform
|
||||||
|
- **Primary Routes**:
|
||||||
|
- `/admin/operations`
|
||||||
|
- `/admin/operations/{run}`
|
||||||
|
- `/admin/t/{tenant}`
|
||||||
|
- `/system/ops/runs/{run}`
|
||||||
|
- **Data Ownership**:
|
||||||
|
- No new platform-owned, workspace-owned, or tenant-owned records are introduced
|
||||||
|
- Existing `OperationRun` records remain the only source of truth for operation history, status, and deep-link destinations
|
||||||
|
- Existing notifications, related-navigation payloads, reference summaries, and embedded report components remain derived presentation layers only
|
||||||
|
- **RBAC**:
|
||||||
|
- No new capability, role, or authorization plane is introduced
|
||||||
|
- Existing admin, tenant, and platform route guards remain authoritative for whether an operator may open a given operation destination
|
||||||
|
- This feature changes visible operator vocabulary only; it does not widen access to any operation route or action
|
||||||
|
|
||||||
|
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Surface Type | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Shared operation links and related navigation | Embedded related-navigation affordance | Explicit safe link to one existing operation | n/a | inline or footer placement only | none | panel-appropriate operations list | panel-appropriate operation detail | current panel and current scope remain legible | Operations / Operation | destination label, operation identity, scope cue | none |
|
||||||
|
| Tenantless/admin operation detail viewers | Detail-first Operational Surface | Dedicated detail page | forbidden | detail header or related-links group only | detail header only | `/admin/operations` or `/system/ops/runs` | panel-appropriate operation detail route | workspace or platform context explicit | Operations / Operation | operation identity, outcome, context, next navigation | none |
|
||||||
|
| Verification and onboarding operation report surfaces | Embedded operator detail panel | One primary CTA to the existing operation when present | forbidden | low-emphasis advanced links only when justified | none | panel-appropriate operations list when needed | panel-appropriate operation detail route | tenant/workspace context and verification context visible | Operations / Operation | verification status, operation identity, recency | none |
|
||||||
|
| Summary and health widgets that reference operations | Embedded status summary | Explicit single CTA or passive summary with no competing link | forbidden | card footer or summary action only | none | panel-appropriate operations list | panel-appropriate operation detail route when singular | current scope stays explicit | Operations / Operation | counts, status family, scope | none |
|
||||||
|
|
||||||
|
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Shared operation links and related navigation | Workspace, tenant, or platform operator | Embedded related-navigation affordance | Which operation does this link open? | explicit `Operation` noun, stable verb, scope cue when needed | raw route names or internal type strings stay hidden | navigation context only | none | `Open operation` or route-equivalent collection navigation | none |
|
||||||
|
| Tenantless/admin operation detail viewers | Workspace or platform operator | Detail-first Operational Surface | What happened on this operation? | operation title, `Operation #<id>`, outcome, scope, next steps | extended payloads and traces stay in diagnostics sections | execution outcome, lifecycle, operability | existing detail-owned actions only | existing detail navigation and triage actions | existing detail-owned destructive actions only |
|
||||||
|
| Verification and onboarding operation report surfaces | Workspace or tenant operator | Embedded operator detail panel | What verification operation ran, and how do I inspect it? | verification state, operation identity, timestamp, one primary link | low-level JSON, hashes, and raw provider details stay behind explicit reveal | verification status, operation recency | none | `Open operation`, workflow-native next-step CTA such as `Start verification` | none |
|
||||||
|
| Summary and health widgets that reference operations | Workspace, tenant, or platform operator | Embedded status summary | Are there failed, stuck, or recent operations I should inspect? | pluralized operation labels, counts, scope, one clear drill-in | raw IDs and detailed traces stay on destination surfaces | count state, health family, recency | none | collection drill-in or none | none |
|
||||||
|
|
||||||
|
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||||
|
|
||||||
|
- **New source of truth?**: No
|
||||||
|
- **New persisted entity/table/artifact?**: No
|
||||||
|
- **New abstraction?**: No
|
||||||
|
- **New enum/state/reason family?**: No
|
||||||
|
- **New cross-domain UI framework/taxonomy?**: No
|
||||||
|
- **Current operator problem**: Outside the system-panel surfaces already aligned by Spec 170, the repo still exposes `View run`, `Run ID`, `Run #...`, and plural `runs` wording across notifications, related links, verification reports, admin viewers, and health widgets, which makes the same `OperationRun` record look like different domain objects depending on where the operator encounters it.
|
||||||
|
- **Existing structure is insufficient because**: Shared label catalogs, reference resolvers, and embedded components still permit local wording drift, so isolated copy fixes would keep reintroducing inconsistent nouns.
|
||||||
|
- **Narrowest correct implementation**: Standardize only operator-visible naming for existing `OperationRun` record references outside Spec 170, reuse the existing routes and destinations, and update the shared label-producing layers that feed multiple surfaces.
|
||||||
|
- **Ownership cost**: Existing feature and guard tests will need wording updates, and a small number of embedded report components will need representative coverage so naming drift does not return.
|
||||||
|
- **Alternative intentionally rejected**: Renaming PHP classes, route slugs, enum values, or persistence structures was rejected because this slice is about operator-visible language, not internal refactoring.
|
||||||
|
- **Release truth**: Current-release truth. The inconsistent labels already exist in the repo today across tenant, workspace, canonical-view, and platform surfaces.
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Shared Operation Links Use One Vocabulary (Priority: P1)
|
||||||
|
|
||||||
|
As an operator, I want every link to an existing operation record to use the same noun and verb pattern, so that I immediately recognize it as the same destination regardless of which page, widget, or notification I came from.
|
||||||
|
|
||||||
|
**Why this priority**: Shared deep-link labels are the highest-leverage root cause because they feed many surfaces at once.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by rendering representative resources, widgets, shared related-navigation payloads, and notifications that link to existing operation records, then asserting that visible labels use `Open operation` and `Operation #<id>` or `Operation ID` rather than `View run`, `Run #`, or `Run ID`.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a resource, widget, or notification exposes a link to an existing operation, **When** the operator sees that affordance, **Then** the visible action uses `Operation` terminology rather than `run` terminology.
|
||||||
|
2. **Given** a shared reference or related-navigation summary exposes an operation identifier, **When** it renders, **Then** the visible identifier uses `Operation #<id>` or `Operation ID` rather than `Run #<id>` or `Run ID`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Verification Surfaces Distinguish Workflow Verbs From Operation Records (Priority: P1)
|
||||||
|
|
||||||
|
As a workspace or tenant operator, I want verification and onboarding surfaces to keep workflow verbs like `Start verification` while naming the resulting historical record as an operation, so that the current task and the inspectable record are not collapsed into one ambiguous `run` concept.
|
||||||
|
|
||||||
|
**Why this priority**: Verification widgets, modals, and onboarding reports currently mix workflow language and operation-record language most visibly.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by rendering representative verification and onboarding report surfaces and asserting that workflow actions keep their intended verbs while record links and identifiers use `Operation` terminology.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a verification surface offers the operator a next step, **When** the action starts new work, **Then** it may continue to use a task verb such as `Start verification`.
|
||||||
|
2. **Given** a verification surface shows a historical or in-progress operation record, **When** the operator inspects the metadata or opens the destination, **Then** the visible link and identifier use `Operation` terminology rather than `run` terminology.
|
||||||
|
3. **Given** no verification-backed operation exists yet, **When** the empty state renders, **Then** the explanation avoids using `run` as a fallback noun for the future record.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Summary Surfaces Use Consistent Operation Plurals (Priority: P2)
|
||||||
|
|
||||||
|
As an operator scanning health and summary surfaces, I want counts and helper text to refer to failed, stuck, or recent operations consistently, so that summary cards and widgets reinforce the same vocabulary as detailed destinations.
|
||||||
|
|
||||||
|
**Why this priority**: Summary copy is lower-risk than deep-link labels, but it is high-frequency and shapes daily operator language.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by rendering representative summary widgets or health indicators and asserting that plural copy uses `operations` instead of `runs` when referring to existing `OperationRun` records.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a summary widget describes failed or stuck execution records, **When** it renders, **Then** it uses `operations` rather than `runs` as the visible plural noun.
|
||||||
|
2. **Given** a platform or tenant health indicator refers to recent operation history, **When** it renders, **Then** the summary reinforces the same `Operations / Operation` vocabulary used by links and details.
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- Workflow verbs that describe starting new work, such as `Start verification`, remain valid when they describe the task being initiated rather than an already-created `OperationRun` record.
|
||||||
|
- Existing internal route slugs, PHP class names, enum values, and table names may continue to use `run` terminology; this slice only governs operator-visible language.
|
||||||
|
- Shared helper layers must not regress Spec 170 system-surface naming while this slice updates non-system surfaces.
|
||||||
|
- A surface may link to either workspace/admin or platform detail, but the visible noun must still describe the destination record as an `Operation`.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
**Constitution alignment (required):** This feature introduces no new execution path, no new outbound work, and no new long-running process. It only standardizes visible operator-facing language for existing `OperationRun` references.
|
||||||
|
|
||||||
|
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature adds no new structure, persistence, abstraction, or state family. It reduces naming drift in the existing presentation layer.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-NAMING-001):** Operator-visible references to `OperationRun` records outside Spec 170 MUST resolve to the canonical visible nouns `Operations` and `Operation`, while internal implementation names may remain stable.
|
||||||
|
|
||||||
|
**Constitution alignment (OPSURF-001):** Shared links, embedded reports, and summary widgets must describe existing operation records with the same vocabulary as the canonical destinations they open.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / UI-HARD-001 / UI-REVIEW-001):** This slice does not introduce new operator surfaces, but it must keep primary navigation labels, identifiers, and summary nouns consistent across existing surfaces so operators do not learn multiple names for the same domain object.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-FIL-001):** Existing Filament actions, widgets, notifications, and embedded components remain the delivery mechanism. No new local UI framework or styling system is introduced.
|
||||||
|
|
||||||
|
**Constitution alignment (TEST-TRUTH-001):** Representative coverage must assert visible operator language on shared links and representative components so naming regressions fail in CI instead of relying on manual review.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-171-001**: Operator-visible references to existing `OperationRun` records outside Spec 170 MUST use `Operations` as the canonical visible collection noun and `Operation` as the canonical visible singular noun.
|
||||||
|
- **FR-171-002**: Operator-visible navigation actions that open a specific existing operation record MUST use `Open operation` or a panel-appropriate equivalent rather than `View run`.
|
||||||
|
- **FR-171-003**: Operator-visible identifier labels for existing operation records MUST use `Operation #<id>` and `Operation ID` rather than `Run #<id>` and `Run ID`.
|
||||||
|
- **FR-171-004**: Verification and onboarding surfaces MAY keep workflow verbs such as `Start verification`, but any visible link, identifier, or helper copy that refers to the resulting historical record MUST use `operation` terminology rather than `run` terminology.
|
||||||
|
- **FR-171-005**: Summary or helper copy that refers to multiple existing `OperationRun` records MUST use `operations` rather than `runs` when the subject is operation history, failures, or stuck work.
|
||||||
|
- **FR-171-006**: The implementation MUST update shared label-producing layers, including catalogs, resolvers, reference presenters, or notification builders, wherever those layers are the source of visible naming drift across multiple surfaces.
|
||||||
|
- **FR-171-007**: This feature MUST NOT rename internal PHP class names, route slugs, enum values, persistence artifacts, or operation type identifiers.
|
||||||
|
- **FR-171-008**: Existing route destinations, authorization checks, and operation lifecycle semantics MUST remain unchanged.
|
||||||
|
- **FR-171-009**: Representative automated coverage MUST verify that covered non-system operator surfaces no longer render `View run`, `Run ID`, or `Run #` when referring to an existing `OperationRun` record.
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Shared related links and references | shared presenters, catalogs, and resolvers used across resources and widgets | n/a | explicit `Open operation` link when present | existing safe link only | n/a | n/a | n/a | n/a | no new audit behavior | Naming-only slice; no new action family |
|
||||||
|
| Tenantless/admin operation viewers | `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` and shared reference summaries | existing viewer actions remain | n/a | existing related links remain | n/a | n/a | existing header actions remain | n/a | no new audit behavior | Title, secondary labels, and related links use `Operation` |
|
||||||
|
| Verification and onboarding report surfaces | tenant widgets and onboarding report/modals under `resources/views/filament/**` and their backing widgets/pages | existing workflow actions remain | n/a | one existing safe operation link when present | n/a | existing workflow CTA remains on owning surface | n/a | n/a | no new audit behavior | Workflow verbs stay task-oriented; record links and IDs use `Operation` |
|
||||||
|
| Summary and health widgets | representative tenant/platform widgets that describe operation history | existing page/widget actions remain | n/a | single existing drill-in or passive summary | n/a | existing surface-specific CTA behavior remains | n/a | n/a | no new audit behavior | Summary nouns use `operations` rather than `runs` |
|
||||||
|
|
||||||
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **Shared operation label source**: Existing catalogs, resolvers, presenters, or notifications that emit operator-facing operation labels.
|
||||||
|
- **Embedded operation report surface**: Existing widget, modal, or Blade component that exposes one operation record inside a broader workflow.
|
||||||
|
- **Operation summary copy**: Existing pluralized helper or status text that describes multiple `OperationRun` records.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
- **SC-171-001**: Representative automated coverage verifies that shared operation links on covered non-system surfaces use `Operation` terminology rather than `run` terminology.
|
||||||
|
- **SC-171-002**: Representative automated coverage verifies that covered verification/onboarding surfaces use `Operation ID` or `Operation #<id>` rather than `Run ID` or `Run #<id>` for existing operation records.
|
||||||
|
- **SC-171-003**: Representative automated coverage verifies that covered summary widgets use plural `operations` wording rather than plural `runs` wording when referring to existing `OperationRun` records.
|
||||||
|
- **SC-171-004**: The feature ships without adding any new route, capability, persistence artifact, or internal rename requirement.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- Spec 170 already owns visible Operations / Operation naming for the system-panel operations surfaces.
|
||||||
|
- Panel-appropriate detail routes remain the correct canonical destinations for existing operation records.
|
||||||
|
- Not every use of the English verb `run` is wrong; this slice only governs operator-visible references to `OperationRun` records.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Renaming PHP classes, routes, tables, or enum values that contain `run`
|
||||||
|
- Reworking the action-surface behavior of deferred dashboard or onboarding surfaces beyond visible naming alignment
|
||||||
|
- Changing operation lifecycle semantics, queued-run feedback, or notifications beyond their visible labels
|
||||||
|
- Revisiting operation type taxonomy, `OperationCatalog` strategy, or provider-domain naming beyond the visible operator copy needed for this slice
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Spec 170 system-surface naming alignment
|
||||||
|
- Existing operation detail routes in admin and system panels
|
||||||
|
- Existing shared label catalogs, related-navigation resolvers, reference presenters, notifications, widgets, and verification report components
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
Spec 171 is complete when representative non-system operator surfaces that refer to existing `OperationRun` records use `Operations / Operation` as the canonical visible noun family, shared deep-link labels and identifiers no longer say `View run`, `Run ID`, or `Run #`, verification surfaces keep task verbs distinct from record nouns, and automated coverage protects the updated naming without requiring route or persistence refactors.
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
# Specification Quality Checklist: Deferred Operator Surfaces Retrofit
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-03-30
|
||||||
|
**Feature**: [spec.md](../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: complete
|
||||||
|
- This spec intentionally targets only deferred non-table surfaces that already expose operation affordances; unrelated deferred pages remain explicit non-goals unless a later spec enrolls them.
|
||||||
180
specs/172-deferred-operator-surfaces-retrofit/spec.md
Normal file
180
specs/172-deferred-operator-surfaces-retrofit/spec.md
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
# Feature Specification: Deferred Operator Surfaces Retrofit
|
||||||
|
|
||||||
|
**Feature Branch**: `172-deferred-operator-surfaces-retrofit`
|
||||||
|
**Created**: 2026-03-30
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Deferred Operator Surfaces Retrofit"
|
||||||
|
|
||||||
|
## Spec Scope Fields *(mandatory)*
|
||||||
|
|
||||||
|
- **Scope**: workspace + tenant + canonical-view
|
||||||
|
- **Primary Routes**:
|
||||||
|
- `/admin/t/{tenant}`
|
||||||
|
- `/admin/operations`
|
||||||
|
- `/admin/operations/{run}`
|
||||||
|
- managed-tenant onboarding flow routes that expose verification operation reports
|
||||||
|
- **Data Ownership**:
|
||||||
|
- No new platform-owned, workspace-owned, or tenant-owned records are introduced
|
||||||
|
- Existing `OperationRun` records remain the only source of truth for operation status, deep-link destinations, and verification history
|
||||||
|
- Tenant dashboard widgets, onboarding report components, and related embedded operation affordances remain derived presentation layers only
|
||||||
|
- **RBAC**:
|
||||||
|
- No new capability family is introduced
|
||||||
|
- Existing tenant, workspace, and admin access rules remain authoritative for every destination that a retrofitted surface may open
|
||||||
|
- Retrofitted surfaces must not imply broader scope than the operator can actually inspect
|
||||||
|
|
||||||
|
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Surface Type | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Tenant dashboard operation cards and widgets | Embedded status summary / drill-in surface | Explicit CTA to a tenant-scoped operations destination | forbidden | card footer or secondary widget action only | none | tenant-scoped operations destination in admin panel | panel-appropriate operation detail when singular | current tenant remains explicit before navigation | Operations / Operation | active or recent operation truth, tenant context, next destination | retrofit existing deferred surface |
|
||||||
|
| Tenant verification report widget | Embedded operator detail panel | One primary inspect CTA to the existing operation when present | forbidden | advanced admin/monitoring link only if justified and clearly secondary | none | panel-appropriate operations collection when needed | panel-appropriate operation detail route | tenant context and current verification state explicit | Operations / Operation | verification state, operation identity, recency | retrofit existing deferred surface |
|
||||||
|
| Managed-tenant onboarding verification report and technical-details surfaces | Guided workflow sub-surface | One primary inspect CTA to the existing operation when present, otherwise one workflow-next-step CTA | forbidden | low-emphasis advanced links only when justified | none | panel-appropriate operations collection when needed | panel-appropriate operation detail route | workspace, tenant, and verification context explicit | Operations / Operation | verification status, operation identity, stale-state explanation | retrofit existing deferred surface |
|
||||||
|
|
||||||
|
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Tenant dashboard operation cards and widgets | Tenant operator | Embedded status summary / drill-in surface | What is happening in this tenant, and where do I inspect it? | tenant-scoped count or recent activity, explicit destination scope, one clear CTA | raw operation payloads and extended traces stay on destination surfaces | recency, active-state, failure/stuck summary | none | tenant-scoped operations drill-in | none |
|
||||||
|
| Tenant verification report widget | Tenant operator | Embedded operator detail panel | What verification operation ran for this tenant, and how do I inspect it? | verification result, one primary operation link, operation identity, timestamp | raw provider diagnostics stay behind explicit reveal or destination detail | verification outcome, recency, stale-state | none | `Open operation` or current-step CTA | none |
|
||||||
|
| Managed-tenant onboarding verification report and technical details | Workspace operator running onboarding | Guided workflow sub-surface | What verification operation supports this onboarding step, and what should I do next? | workflow state, operation identity when present, one primary CTA, scope cue | low-level payloads, hashes, and verbose traces stay in diagnostics sections or canonical detail | workflow status, verification outcome, stale-state | none | `Open operation` or `Start verification` | none |
|
||||||
|
|
||||||
|
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||||
|
|
||||||
|
- **New source of truth?**: No
|
||||||
|
- **New persisted entity/table/artifact?**: No
|
||||||
|
- **New abstraction?**: No
|
||||||
|
- **New enum/state/reason family?**: No
|
||||||
|
- **New cross-domain UI framework/taxonomy?**: No
|
||||||
|
- **Current operator problem**: Dashboard widgets, tenant verification widgets, and onboarding verification components still behave like deferred or exempt surfaces even though they expose meaningful operation drill-ins, which leaves CTA count, scope cues, and deep-link behavior underdefined compared with the now-aligned table and detail surfaces.
|
||||||
|
- **Existing structure is insufficient because**: Spec 169 intentionally left these embedded surfaces out of the table-centric action-surface enforcement path, so the repo still permits tenant-context leaks, competing links, and scope-ambiguous operation affordances on high-traffic summary surfaces.
|
||||||
|
- **Narrowest correct implementation**: Retrofit only the deferred non-table surfaces that already expose operation affordances, give them explicit operator contracts and representative coverage, and keep unrelated deferred pages out of scope.
|
||||||
|
- **Ownership cost**: Existing dashboard and onboarding tests will need to assert CTA count, destination scope, and advanced-link visibility, and the exemption baseline or equivalent governance notes must be narrowed for the retrofitted surfaces.
|
||||||
|
- **Alternative intentionally rejected**: Broadly enrolling every deferred dashboard, chooser, landing page, or page-class route into the main action-surface validator was rejected because this slice only needs to retrofit operation-bearing embedded surfaces, not redesign every deferred surface family.
|
||||||
|
- **Release truth**: Current-release truth. The tenant dashboard and onboarding verification flows already expose operation links today, and current audits show scope and affordance drift on those surfaces.
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Tenant Dashboard Drill-Ins Preserve Tenant Context (Priority: P1)
|
||||||
|
|
||||||
|
As a tenant operator, I want dashboard operation cards and widgets to send me to a destination that clearly preserves my current tenant context, so that I am not silently dropped into a broader workspace-wide operations surface.
|
||||||
|
|
||||||
|
**Why this priority**: Tenant dashboard drill-ins are a frequent entry point and currently carry the clearest cross-scope surprise risk.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by rendering the relevant tenant dashboard operation affordances and asserting that their visible destination semantics remain tenant-scoped and do not silently link to an unfiltered workspace-wide operations surface.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a tenant operator on the tenant dashboard, **When** the operator opens a dashboard operation drill-in, **Then** the destination preserves tenant context or makes the broader scope explicit before navigation.
|
||||||
|
2. **Given** a tenant dashboard widget summarizes recent or active operations, **When** it renders, **Then** it exposes at most one primary operations drill-in rather than multiple competing operation links.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Onboarding And Verification Surfaces Expose One Clear Operation Path (Priority: P1)
|
||||||
|
|
||||||
|
As a workspace or tenant operator, I want verification widgets, onboarding report blocks, and technical-detail overlays to show one clear primary action for the relevant operation record, so that I know exactly where to inspect execution truth without sorting through competing links.
|
||||||
|
|
||||||
|
**Why this priority**: These surfaces are currently exempt yet already act like operator-facing execution summaries.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by rendering representative tenant verification and onboarding verification surfaces in empty, in-progress, and completed states and asserting that each state has one primary CTA aligned to the operator's next step.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a verification-backed operation exists, **When** the report surface renders, **Then** exactly one primary `Open operation` affordance is visible for that record.
|
||||||
|
2. **Given** no verification-backed operation exists yet, **When** the owning verification or onboarding surface renders, **Then** the operator sees one clear next-step CTA such as `Start verification` and no competing inline operation links.
|
||||||
|
3. **Given** an advanced monitoring destination is still useful for some operators, **When** it is shown, **Then** it is explicitly secondary and does not compete with the primary inspect path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Retrofitted Deferred Surfaces Gain Explicit Governance (Priority: P2)
|
||||||
|
|
||||||
|
As a reviewer, I want the deferred surfaces that already expose operations to stop relying on blanket exemptions, so that future regressions in CTA count, scope signals, or deep-link behavior are caught automatically.
|
||||||
|
|
||||||
|
**Why this priority**: Without explicit governance, the retrofitted surfaces will remain drift-prone even after a one-time UX fix.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by proving that representative tenant dashboard and onboarding verification surfaces are covered by dedicated tests or governance checks, while unrelated deferred surfaces remain explicit non-goals.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the retrofit is complete, **When** representative tests or governance checks run, **Then** tenant dashboard and onboarding verification operation affordances are covered explicitly rather than relying on a blanket deferred exemption.
|
||||||
|
2. **Given** unrelated deferred surfaces such as chooser pages or landing pages remain untouched, **When** the retrofit ships, **Then** they remain explicit non-goals rather than being swept in accidentally.
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- A tenant verification surface may need to show no current operation, an in-progress operation, or a stale completed operation; each state still needs one primary next-step affordance.
|
||||||
|
- Some operators may have access to the tenant-local surface but not to an advanced admin monitoring destination; advanced links must respect destination access.
|
||||||
|
- A retrofitted surface may expose a collection drill-in or a single-operation drill-in depending on context, but the scope must remain explicit either way.
|
||||||
|
- Unrelated deferred surfaces such as `ChooseTenant`, `ChooseWorkspace`, `ManagedTenantsLanding`, and the Monitoring Alerts page-class route remain out of scope unless they later gain operation affordances that justify a dedicated spec.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
**Constitution alignment (required):** This feature introduces no new execution path, no new long-running work, and no new persistence. It only retrofits existing embedded operator surfaces that already summarize or link to `OperationRun` records.
|
||||||
|
|
||||||
|
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature adds no new structure, persistence, abstraction, or state family. It narrows an existing deferred UX gap on current-release surfaces.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / UI-HARD-001 / UI-REVIEW-001):** Even though these are embedded or guided-flow surfaces rather than table pages, they must still expose one clear primary inspect or next-step model, keep scope truthful, and avoid competing actions.
|
||||||
|
|
||||||
|
**Constitution alignment (OPSURF-001):** Dashboard and onboarding verification surfaces must be operator-first summary surfaces: the default-visible content should answer what happened, what scope it affected, and where the operator should go next without exposing low-level diagnostics by default.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-FIL-001):** Existing Filament pages, widgets, and embedded components remain the implementation shape. No local mini-framework for embedded operation actions is introduced.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-NAMING-001):** The retrofitted surfaces use the canonical visible nouns `Operations` and `Operation`, consistent with Specs 170 and 171.
|
||||||
|
|
||||||
|
**Constitution alignment (TEST-TRUTH-001):** Representative automated coverage must assert CTA count, scope cues, and visibility of advanced links on the retrofitted surfaces.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-172-001**: Tenant dashboard operation-bearing widgets or cards MUST expose navigation that preserves tenant context or makes any broader scope explicit before the operator leaves the tenant dashboard.
|
||||||
|
- **FR-172-002**: Retrofitted embedded surfaces that reference one existing operation record MUST expose exactly one primary inspect affordance for that record.
|
||||||
|
- **FR-172-003**: Retrofitted embedded surfaces in a no-history or no-operation state MUST expose exactly one primary next-step CTA on the owning surface and MUST NOT render competing inline operation links.
|
||||||
|
- **FR-172-004**: Any retained advanced admin or monitoring destination link MUST be clearly secondary, explicitly labeled for its scope or audience, and visible only when the operator can access that destination.
|
||||||
|
- **FR-172-005**: Retrofitted tenant or workspace surfaces MUST keep tenant, workspace, or admin scope explicit in their visible copy or destination semantics before navigation occurs.
|
||||||
|
- **FR-172-006**: Governance artifacts, exemption handling, or dedicated tests MUST stop treating the retrofitted operation-bearing parts of `TenantDashboard` and `ManagedTenantOnboardingWizard` as fully out of scope.
|
||||||
|
- **FR-172-007**: Unrelated deferred surfaces that do not expose operation affordances today, including `ChooseTenant`, `ChooseWorkspace`, `ManagedTenantsLanding`, and the Monitoring Alerts page-class route, MUST remain explicit non-goals for this slice.
|
||||||
|
- **FR-172-008**: Existing operation destinations, authorization rules, and lifecycle semantics MUST remain unchanged.
|
||||||
|
- **FR-172-009**: Representative automated coverage MUST verify CTA count, scope-truthful navigation, and advanced-link visibility on the tenant dashboard and onboarding verification surfaces affected by this slice.
|
||||||
|
- **FR-172-010**: This feature MUST NOT introduce a new dashboard page, a new onboarding flow, or a new operations capability.
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Tenant dashboard operation cards/widgets | `app/Filament/Pages/TenantDashboard.php` and its operation-bearing widgets | existing dashboard/header actions remain | n/a | one explicit operations drill-in per card/widget maximum | n/a | existing surface-specific next-step CTA remains singular | n/a | n/a | no new audit behavior | Retrofit current deferred widget/card surfaces with scope-truthful operations drill-ins |
|
||||||
|
| Tenant verification report widget | tenant verification widget and embedded report view | existing widget actions remain | n/a | one primary `Open operation` link when a record exists | n/a | owning surface keeps one workflow CTA when no operation exists | n/a | n/a | no new audit behavior | Advanced admin/monitoring link may remain only as secondary |
|
||||||
|
| Managed-tenant onboarding verification report and technical details | `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` and related onboarding report/modal views | existing workflow actions remain | n/a | one primary `Open operation` link when a record exists | n/a | `Start verification` or equivalent next-step CTA remains singular when no operation exists | n/a | n/a | no new audit behavior | Guided-flow retrofit; no new page or route family |
|
||||||
|
|
||||||
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **Deferred operation-bearing embedded surface**: Existing dashboard card, widget, report block, or modal that is not a table page but still exposes operation truth or navigation.
|
||||||
|
- **Primary inspect affordance**: The one visible link or CTA that opens the canonical operation destination for the current embedded context.
|
||||||
|
- **Advanced destination link**: A clearly secondary operation-related link that exposes a broader monitoring destination only for operators who can access it.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
- **SC-172-001**: Representative automated coverage verifies that tenant dashboard operation drill-ins preserve tenant context or make any broader scope explicit before navigation.
|
||||||
|
- **SC-172-002**: Representative automated coverage verifies that covered verification and onboarding surfaces expose exactly one primary CTA per state.
|
||||||
|
- **SC-172-003**: Representative automated coverage verifies that any retained advanced monitoring/admin link is secondary and access-aware.
|
||||||
|
- **SC-172-004**: The feature ships without adding a new dashboard page, onboarding flow, operations capability, or persistence artifact.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- Specs 170 and 171 establish the canonical visible noun family `Operations / Operation` for aligned and residual naming surfaces.
|
||||||
|
- Existing admin operation list and detail destinations remain the correct canonical inspect targets for embedded tenant/workspace surfaces.
|
||||||
|
- Retrofitting embedded operation-bearing surfaces is sufficient for this slice; not every currently exempt page needs to be enrolled.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Building a new workspace home or redesigning the tenant dashboard as a whole
|
||||||
|
- Reworking onboarding flow mechanics or verification execution semantics
|
||||||
|
- Enrolling chooser pages, `ManagedTenantsLanding`, or the Monitoring Alerts page-class route into this retrofit when they do not currently expose operation affordances that need the same treatment
|
||||||
|
- Introducing a broad action-surface framework for all widgets and embedded components beyond the explicit retrofits in this slice
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Spec 169 deferred-surface exemption baseline
|
||||||
|
- Spec 170 system operations surface alignment
|
||||||
|
- Spec 171 operations naming consolidation
|
||||||
|
- Existing tenant dashboard widgets, verification report widgets, onboarding verification report components, and canonical admin operation destinations
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
Spec 172 is complete when the deferred non-table surfaces that already expose operations, especially tenant dashboard operation drill-ins and onboarding/verification report surfaces, provide one clear primary CTA per state, preserve or explicitly announce scope before navigation, keep any advanced monitoring/admin links secondary and access-aware, and are protected by explicit governance or representative tests instead of relying on a blanket deferred exemption.
|
||||||
@ -4,9 +4,13 @@
|
|||||||
|
|
||||||
use App\Filament\Pages\InventoryCoverage;
|
use App\Filament\Pages\InventoryCoverage;
|
||||||
use App\Filament\Pages\Monitoring\Alerts;
|
use App\Filament\Pages\Monitoring\Alerts;
|
||||||
|
use App\Filament\Pages\Monitoring\AuditLog as AuditLogPage;
|
||||||
|
use App\Filament\Pages\Monitoring\EvidenceOverview;
|
||||||
|
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
|
||||||
use App\Filament\Pages\Monitoring\Operations;
|
use App\Filament\Pages\Monitoring\Operations;
|
||||||
use App\Filament\Pages\NoAccess;
|
use App\Filament\Pages\NoAccess;
|
||||||
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
|
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
|
||||||
|
use App\Filament\Pages\Reviews\ReviewRegister;
|
||||||
use App\Filament\Pages\TenantDiagnostics;
|
use App\Filament\Pages\TenantDiagnostics;
|
||||||
use App\Filament\Pages\TenantRequiredPermissions;
|
use App\Filament\Pages\TenantRequiredPermissions;
|
||||||
use App\Filament\Resources\AlertDeliveryResource;
|
use App\Filament\Resources\AlertDeliveryResource;
|
||||||
@ -73,6 +77,7 @@
|
|||||||
use App\Models\BaselineProfile;
|
use App\Models\BaselineProfile;
|
||||||
use App\Models\EvidenceSnapshot;
|
use App\Models\EvidenceSnapshot;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
|
use App\Models\FindingException;
|
||||||
use App\Models\InventoryItem;
|
use App\Models\InventoryItem;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\PlatformUser;
|
use App\Models\PlatformUser;
|
||||||
@ -97,10 +102,13 @@
|
|||||||
use App\Support\Ui\ActionSurface\ActionSurfaceExemptions;
|
use App\Support\Ui\ActionSurface\ActionSurfaceExemptions;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceProfileDefinition;
|
use App\Support\Ui\ActionSurface\ActionSurfaceProfileDefinition;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceValidator;
|
use App\Support\Ui\ActionSurface\ActionSurfaceValidator;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfacePanelScope;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfacePanelScope;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||||
use App\Support\WorkspaceIsolation\TenantOwnedModelFamilies;
|
use App\Support\WorkspaceIsolation\TenantOwnedModelFamilies;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
use Filament\Actions\BulkActionGroup;
|
use Filament\Actions\BulkActionGroup;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
@ -238,7 +246,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
expect($moreActionNames)->toContain('archive');
|
expect($moreActionNames)->toBe(['edit', 'archive']);
|
||||||
expect($table->getBulkActions())->toBeEmpty();
|
expect($table->getBulkActions())->toBeEmpty();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -288,7 +296,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
expect($moreActionNames)->toContain('runNow', 'retry', 'archive')
|
expect($moreActionNames)->toBe(['runNow', 'retry', 'restore', 'archive', 'forceDelete'])
|
||||||
->and($moreActionNames)->not->toContain('edit');
|
->and($moreActionNames)->not->toContain('edit');
|
||||||
|
|
||||||
$bulkActions = $table->getBulkActions();
|
$bulkActions = $table->getBulkActions();
|
||||||
@ -303,7 +311,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
expect($bulkActionNames)->toEqualCanonicalizing(['bulk_run_now', 'bulk_retry']);
|
expect($bulkActionNames)->toBe(['bulk_run_now', 'bulk_retry']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses clickable rows without extra row actions on backup schedule executions', function (): void {
|
it('uses clickable rows without extra row actions on backup schedule executions', function (): void {
|
||||||
@ -743,6 +751,9 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
|
|
||||||
expect(method_exists(\App\Filament\System\Pages\Ops\Runbooks::class, 'actionSurfaceDeclaration'))->toBeFalse()
|
expect(method_exists(\App\Filament\System\Pages\Ops\Runbooks::class, 'actionSurfaceDeclaration'))->toBeFalse()
|
||||||
->and($baselineExemptions->hasClass(\App\Filament\System\Pages\Ops\Runbooks::class))->toBeFalse();
|
->and($baselineExemptions->hasClass(\App\Filament\System\Pages\Ops\Runbooks::class))->toBeFalse();
|
||||||
|
|
||||||
|
expect(method_exists(\App\Filament\System\Pages\RepairWorkspaceOwners::class, 'actionSurfaceDeclaration'))->toBeFalse()
|
||||||
|
->and($baselineExemptions->hasClass(\App\Filament\System\Pages\RepairWorkspaceOwners::class))->toBeFalse();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps enrolled system panel pages declaration-backed without stale baseline exemptions', function (): void {
|
it('keeps enrolled system panel pages declaration-backed without stale baseline exemptions', function (): void {
|
||||||
@ -764,6 +775,31 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('discovers only the enrolled system table pages in the primary validator pass', function (): void {
|
||||||
|
$components = collect(ActionSurfaceValidator::withBaselineExemptions()->discoveredComponents())
|
||||||
|
->keyBy('className');
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
SystemRunsPage::class,
|
||||||
|
SystemFailuresPage::class,
|
||||||
|
SystemStuckPage::class,
|
||||||
|
SystemDirectoryTenantsPage::class,
|
||||||
|
SystemDirectoryWorkspacesPage::class,
|
||||||
|
SystemAccessLogsPage::class,
|
||||||
|
] as $className) {
|
||||||
|
expect($components->has($className))
|
||||||
|
->toBeTrue("{$className} should be discovered by the primary validator.");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
\App\Filament\System\Pages\Ops\Runbooks::class,
|
||||||
|
\App\Filament\System\Pages\RepairWorkspaceOwners::class,
|
||||||
|
] as $className) {
|
||||||
|
expect($components->has($className))
|
||||||
|
->toBeFalse("{$className} should stay out of the primary validator discovery scope.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps enrolled relation managers declaration-backed without stale baseline exemptions', function (): void {
|
it('keeps enrolled relation managers declaration-backed without stale baseline exemptions', function (): void {
|
||||||
$baselineExemptions = ActionSurfaceExemptions::baseline();
|
$baselineExemptions = ActionSurfaceExemptions::baseline();
|
||||||
|
|
||||||
@ -1195,11 +1231,124 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
->assertCanSeeTableRecords([$run]);
|
->assertCanSeeTableRecords([$run]);
|
||||||
|
|
||||||
$table = $livewire->instance()->getTable();
|
$table = $livewire->instance()->getTable();
|
||||||
|
$operationsDeclaration = Operations::actionSurfaceDeclaration();
|
||||||
|
$operationRunDeclaration = OperationRunResource::actionSurfaceDeclaration();
|
||||||
|
|
||||||
expect($table->getActions())->toBeEmpty()
|
expect($operationsDeclaration->surfaceType)->toBe(ActionSurfaceType::ReadOnlyRegistryReport)
|
||||||
|
->and($operationRunDeclaration->surfaceType)->toBe(ActionSurfaceType::ReadOnlyRegistryReport)
|
||||||
|
->and($operationsDeclaration->slot(ActionSurfaceSlot::InspectAffordance)?->details)->toBe(ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
|
->and($operationRunDeclaration->slot(ActionSurfaceSlot::InspectAffordance)?->details)->toBe(ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
|
->and($table->getActions())->toBeEmpty()
|
||||||
->and($table->getRecordUrl($run))->toBe(OperationRunLinks::tenantlessView($run));
|
->and($table->getRecordUrl($run))->toBe(OperationRunLinks::tenantlessView($run));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps review and evidence references on clickable-row open without duplicate inspect actions', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$review = composeTenantReviewForTest($tenant, $user)->load('evidenceSnapshot');
|
||||||
|
$snapshot = $review->evidenceSnapshot;
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
setAdminPanelContext();
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
$reviewComponent = Livewire::actingAs($user)
|
||||||
|
->test(ReviewRegister::class)
|
||||||
|
->assertCanSeeTableRecords([$review]);
|
||||||
|
|
||||||
|
$reviewTable = $reviewComponent->instance()->getTable();
|
||||||
|
$reviewActionNames = collect($reviewTable->getActions())
|
||||||
|
->map(static fn ($action): ?string => $action->getName())
|
||||||
|
->filter()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$evidenceComponent = Livewire::actingAs($user)->test(EvidenceOverview::class);
|
||||||
|
$evidenceRows = collect($evidenceComponent->instance()->rows);
|
||||||
|
$evidenceRow = $evidenceRows->firstWhere('snapshot_id', (int) $snapshot->getKey());
|
||||||
|
|
||||||
|
expect(ReviewRegister::actionSurfaceDeclaration()->surfaceType)->toBe(ActionSurfaceType::ReadOnlyRegistryReport)
|
||||||
|
->and(ReviewRegister::actionSurfaceDeclaration()->slot(ActionSurfaceSlot::InspectAffordance)?->details)->toBe(ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
|
->and($reviewActionNames)->not->toContain('view_review')
|
||||||
|
->and($reviewActionNames)->toContain('export_executive_pack')
|
||||||
|
->and($reviewTable->getRecordUrl($review))->toBe(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $review->tenant, 'tenant'))
|
||||||
|
->and(EvidenceOverview::actionSurfaceDeclaration()->surfaceType)->toBe(ActionSurfaceType::ReadOnlyRegistryReport)
|
||||||
|
->and(EvidenceOverview::actionSurfaceDeclaration()->slot(ActionSurfaceSlot::InspectAffordance)?->details)->toBe(ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
|
->and($snapshot)->toBeInstanceOf(EvidenceSnapshot::class)
|
||||||
|
->and(is_array($evidenceRow))->toBeTrue()
|
||||||
|
->and($evidenceRow['view_url'] ?? null)->toBe(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps audit and queue references on explicit inspect without row-click navigation', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner', workspaceRole: 'manager');
|
||||||
|
|
||||||
|
$audit = AuditLog::query()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'actor_email' => 'owner@example.com',
|
||||||
|
'actor_name' => 'Owner',
|
||||||
|
'actor_type' => 'human',
|
||||||
|
'action' => 'workspace.selected',
|
||||||
|
'status' => 'success',
|
||||||
|
'resource_type' => 'workspace',
|
||||||
|
'resource_id' => (string) $tenant->workspace_id,
|
||||||
|
'target_label' => 'Workspace '.$tenant->workspace_id,
|
||||||
|
'summary' => 'Workspace selected for Workspace '.$tenant->workspace_id,
|
||||||
|
'metadata' => ['reason' => 'guard-test'],
|
||||||
|
'recorded_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$finding = Finding::factory()->for($tenant)->create();
|
||||||
|
$exception = FindingException::query()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'finding_id' => (int) $finding->getKey(),
|
||||||
|
'requested_by_user_id' => (int) $user->getKey(),
|
||||||
|
'owner_user_id' => (int) $user->getKey(),
|
||||||
|
'status' => FindingException::STATUS_PENDING,
|
||||||
|
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
|
||||||
|
'request_reason' => 'Guard test exception review',
|
||||||
|
'requested_at' => now()->subDay(),
|
||||||
|
'review_due_at' => now()->addDay(),
|
||||||
|
'evidence_summary' => ['reference_count' => 0],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
setAdminPanelContext();
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
$auditComponent = Livewire::actingAs($user)
|
||||||
|
->test(AuditLogPage::class)
|
||||||
|
->assertCanSeeTableRecords([$audit]);
|
||||||
|
|
||||||
|
$auditTable = $auditComponent->instance()->getTable();
|
||||||
|
$auditActionNames = collect($auditTable->getActions())
|
||||||
|
->map(static fn ($action): ?string => $action->getName())
|
||||||
|
->filter()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$queueComponent = Livewire::actingAs($user)
|
||||||
|
->test(FindingExceptionsQueue::class)
|
||||||
|
->assertCanSeeTableRecords([$exception]);
|
||||||
|
|
||||||
|
$queueTable = $queueComponent->instance()->getTable();
|
||||||
|
$queueActionNames = collect($queueTable->getActions())
|
||||||
|
->map(static fn ($action): ?string => $action->getName())
|
||||||
|
->filter()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
expect(AuditLogPage::actionSurfaceDeclaration()->surfaceType)->toBe(ActionSurfaceType::HistoryAudit)
|
||||||
|
->and(AuditLogPage::actionSurfaceDeclaration()->slot(ActionSurfaceSlot::InspectAffordance)?->details)->toBe(ActionSurfaceInspectAffordance::ViewAction->value)
|
||||||
|
->and($auditActionNames)->toEqualCanonicalizing(['inspect'])
|
||||||
|
->and($auditTable->getRecordUrl($audit))->toBeNull()
|
||||||
|
->and(FindingExceptionsQueue::actionSurfaceDeclaration()->surfaceType)->toBe(ActionSurfaceType::QueueReview)
|
||||||
|
->and(FindingExceptionsQueue::actionSurfaceDeclaration()->slot(ActionSurfaceSlot::InspectAffordance)?->details)->toBe(ActionSurfaceInspectAffordance::ViewAction->value)
|
||||||
|
->and($queueActionNames)->toEqualCanonicalizing(['inspect_exception'])
|
||||||
|
->and($queueTable->getRecordUrl($exception))->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps tenantless run detail header actions on the canonical viewer without list affordances', function (): void {
|
it('keeps tenantless run detail header actions on the canonical viewer without list affordances', function (): void {
|
||||||
$workspace = Workspace::factory()->create();
|
$workspace = Workspace::factory()->create();
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
@ -1288,7 +1437,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
->toContain('no-data');
|
->toContain('no-data');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses clickable rows with direct triage actions on the system runs list', function (): void {
|
it('uses clickable rows without row triage on the system runs list', function (): void {
|
||||||
$run = OperationRun::factory()->create([
|
$run = OperationRun::factory()->create([
|
||||||
'status' => OperationRunStatus::Completed->value,
|
'status' => OperationRunStatus::Completed->value,
|
||||||
'outcome' => OperationRunOutcome::Failed->value,
|
'outcome' => OperationRunOutcome::Failed->value,
|
||||||
@ -1301,21 +1450,25 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$livewire = Livewire::test(SystemRunsPage::class)
|
$livewire = Livewire::test(SystemRunsPage::class)
|
||||||
->assertCanSeeTableRecords([$run]);
|
->assertCanSeeTableRecords([$run])
|
||||||
|
->assertActionVisible('go_to_runbooks')
|
||||||
|
->assertActionExists('go_to_runbooks', fn (Action $action): bool => $action->getLabel() === 'Go to runbooks' && $action->getUrl() === \App\Filament\System\Pages\Ops\Runbooks::getUrl(panel: 'system'));
|
||||||
|
|
||||||
$table = $livewire->instance()->getTable();
|
$table = $livewire->instance()->getTable();
|
||||||
$rowActionNames = collect($table->getActions())
|
$emptyStateActions = collect($table->getEmptyStateActions())
|
||||||
->map(static fn ($action): ?string => $action->getName())
|
->map(static fn ($action): ?string => $action->getName())
|
||||||
->filter()
|
->filter()
|
||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
expect($rowActionNames)->toEqualCanonicalizing(['retry', 'cancel', 'mark_investigated'])
|
expect($livewire->instance()->getTitle())->toBe('Operations')
|
||||||
|
->and($table->getActions())->toBeEmpty()
|
||||||
->and($table->getBulkActions())->toBeEmpty()
|
->and($table->getBulkActions())->toBeEmpty()
|
||||||
|
->and($emptyStateActions)->toBe(['go_to_runbooks_empty'])
|
||||||
->and($table->getRecordUrl($run))->toBe(SystemOperationRunLinks::view($run));
|
->and($table->getRecordUrl($run))->toBe(SystemOperationRunLinks::view($run));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses clickable rows with direct triage actions on the system failures list', function (): void {
|
it('uses clickable rows without row triage on the system failures list', function (): void {
|
||||||
$run = OperationRun::factory()->create([
|
$run = OperationRun::factory()->create([
|
||||||
'status' => OperationRunStatus::Completed->value,
|
'status' => OperationRunStatus::Completed->value,
|
||||||
'outcome' => OperationRunOutcome::Failed->value,
|
'outcome' => OperationRunOutcome::Failed->value,
|
||||||
@ -1328,21 +1481,25 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$livewire = Livewire::test(SystemFailuresPage::class)
|
$livewire = Livewire::test(SystemFailuresPage::class)
|
||||||
->assertCanSeeTableRecords([$run]);
|
->assertCanSeeTableRecords([$run])
|
||||||
|
->assertActionVisible('show_all_operations')
|
||||||
|
->assertActionExists('show_all_operations', fn (Action $action): bool => $action->getLabel() === 'Show all operations' && $action->getUrl() === SystemOperationRunLinks::index());
|
||||||
|
|
||||||
$table = $livewire->instance()->getTable();
|
$table = $livewire->instance()->getTable();
|
||||||
$rowActionNames = collect($table->getActions())
|
$emptyStateActions = collect($table->getEmptyStateActions())
|
||||||
->map(static fn ($action): ?string => $action->getName())
|
->map(static fn ($action): ?string => $action->getName())
|
||||||
->filter()
|
->filter()
|
||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
expect($rowActionNames)->toEqualCanonicalizing(['retry', 'cancel', 'mark_investigated'])
|
expect($livewire->instance()->getTitle())->toBe('Failed operations')
|
||||||
|
->and($table->getActions())->toBeEmpty()
|
||||||
->and($table->getBulkActions())->toBeEmpty()
|
->and($table->getBulkActions())->toBeEmpty()
|
||||||
|
->and($emptyStateActions)->toBe(['show_all_operations_empty'])
|
||||||
->and($table->getRecordUrl($run))->toBe(SystemOperationRunLinks::view($run));
|
->and($table->getRecordUrl($run))->toBe(SystemOperationRunLinks::view($run));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses clickable rows with direct triage actions on the system stuck list', function (): void {
|
it('uses clickable rows without row triage on the system stuck list', function (): void {
|
||||||
$run = OperationRun::factory()->create([
|
$run = OperationRun::factory()->create([
|
||||||
'status' => OperationRunStatus::Queued->value,
|
'status' => OperationRunStatus::Queued->value,
|
||||||
'outcome' => OperationRunOutcome::Pending->value,
|
'outcome' => OperationRunOutcome::Pending->value,
|
||||||
@ -1357,17 +1514,21 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$livewire = Livewire::test(SystemStuckPage::class)
|
$livewire = Livewire::test(SystemStuckPage::class)
|
||||||
->assertCanSeeTableRecords([$run]);
|
->assertCanSeeTableRecords([$run])
|
||||||
|
->assertActionVisible('show_all_operations')
|
||||||
|
->assertActionExists('show_all_operations', fn (Action $action): bool => $action->getLabel() === 'Show all operations' && $action->getUrl() === SystemOperationRunLinks::index());
|
||||||
|
|
||||||
$table = $livewire->instance()->getTable();
|
$table = $livewire->instance()->getTable();
|
||||||
$rowActionNames = collect($table->getActions())
|
$emptyStateActions = collect($table->getEmptyStateActions())
|
||||||
->map(static fn ($action): ?string => $action->getName())
|
->map(static fn ($action): ?string => $action->getName())
|
||||||
->filter()
|
->filter()
|
||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
expect($rowActionNames)->toEqualCanonicalizing(['retry', 'cancel', 'mark_investigated'])
|
expect($livewire->instance()->getTitle())->toBe('Stuck operations')
|
||||||
|
->and($table->getActions())->toBeEmpty()
|
||||||
->and($table->getBulkActions())->toBeEmpty()
|
->and($table->getBulkActions())->toBeEmpty()
|
||||||
|
->and($emptyStateActions)->toBe(['show_all_operations_empty'])
|
||||||
->and($table->getRecordUrl($run))->toBe(SystemOperationRunLinks::view($run));
|
->and($table->getRecordUrl($run))->toBe(SystemOperationRunLinks::view($run));
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1475,13 +1636,24 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
->assertCanSeeTableRecords([$workspace]);
|
->assertCanSeeTableRecords([$workspace]);
|
||||||
|
|
||||||
$table = $livewire->instance()->getTable();
|
$table = $livewire->instance()->getTable();
|
||||||
$rowActionNames = collect($table->getActions())
|
$rowActions = $table->getActions();
|
||||||
|
$rowActionNames = collect($rowActions)
|
||||||
|
->reject(static fn ($action): bool => $action instanceof ActionGroup)
|
||||||
|
->map(static fn ($action): ?string => $action->getName())
|
||||||
|
->filter()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
$moreGroup = collect($rowActions)->first(static fn ($action): bool => $action instanceof ActionGroup);
|
||||||
|
$moreActionNames = collect($moreGroup?->getActions() ?? [])
|
||||||
->map(static fn ($action): ?string => $action->getName())
|
->map(static fn ($action): ?string => $action->getName())
|
||||||
->filter()
|
->filter()
|
||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
expect($rowActionNames)->toContain('edit')
|
expect($rowActionNames)->toBe([])
|
||||||
|
->and($moreGroup)->toBeInstanceOf(ActionGroup::class)
|
||||||
|
->and($moreGroup?->getLabel())->toBe('More')
|
||||||
|
->and($moreActionNames)->toBe(['edit'])
|
||||||
->and($rowActionNames)->not->toContain('view')
|
->and($rowActionNames)->not->toContain('view')
|
||||||
->and($table->getRecordUrl($workspace))->toBe(WorkspaceResource::getUrl('view', ['record' => $workspace]));
|
->and($table->getRecordUrl($workspace))->toBe(WorkspaceResource::getUrl('view', ['record' => $workspace]));
|
||||||
});
|
});
|
||||||
@ -1506,14 +1678,34 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
->assertCanSeeTableRecords([$policy]);
|
->assertCanSeeTableRecords([$policy]);
|
||||||
|
|
||||||
$table = $livewire->instance()->getTable();
|
$table = $livewire->instance()->getTable();
|
||||||
$rowActionNames = collect($table->getActions())
|
$rowActions = $table->getActions();
|
||||||
|
$rowActionNames = collect($rowActions)
|
||||||
->reject(static fn ($action): bool => $action instanceof ActionGroup)
|
->reject(static fn ($action): bool => $action instanceof ActionGroup)
|
||||||
->map(static fn ($action): ?string => $action->getName())
|
->map(static fn ($action): ?string => $action->getName())
|
||||||
->filter()
|
->filter()
|
||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
|
$moreGroup = collect($rowActions)->first(static fn ($action): bool => $action instanceof ActionGroup);
|
||||||
|
$moreActionNames = collect($moreGroup?->getActions() ?? [])
|
||||||
|
->map(static fn ($action): ?string => $action->getName())
|
||||||
|
->filter()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
$bulkGroup = collect($table->getBulkActions())->first(static fn ($action): bool => $action instanceof BulkActionGroup);
|
||||||
|
$bulkActionNames = collect($bulkGroup?->getActions() ?? [])
|
||||||
|
->map(static fn ($action): ?string => $action->getName())
|
||||||
|
->filter()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
expect($rowActionNames)->not->toContain('view')
|
expect($rowActionNames)->toBe([])
|
||||||
|
->and($moreGroup)->toBeInstanceOf(ActionGroup::class)
|
||||||
|
->and($moreGroup?->getLabel())->toBe('More')
|
||||||
|
->and($moreActionNames)->toBe(['export', 'sync', 'restore', 'ignore'])
|
||||||
|
->and($bulkGroup)->toBeInstanceOf(BulkActionGroup::class)
|
||||||
|
->and($bulkGroup?->getLabel())->toBe('More')
|
||||||
|
->and($bulkActionNames)->toBe(['bulk_export', 'bulk_sync', 'bulk_restore', 'bulk_delete'])
|
||||||
|
->and($rowActionNames)->not->toContain('view')
|
||||||
->and($table->getRecordUrl($policy))->toBe(PolicyResource::getUrl('view', ['record' => $policy]));
|
->and($table->getRecordUrl($policy))->toBe(PolicyResource::getUrl('view', ['record' => $policy]));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -13,14 +13,15 @@
|
|||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfacePanelScope;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfacePanelScope;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||||
|
|
||||||
final class ActionSurfaceValidatorCompleteStub
|
final class ActionSurfaceValidatorCompleteStub
|
||||||
{
|
{
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::CrudListFirstResource)
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader)
|
->satisfy(ActionSurfaceSlot::ListHeader)
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu)
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu)
|
||||||
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup)
|
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup)
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState)
|
->satisfy(ActionSurfaceSlot::ListEmptyState)
|
||||||
@ -32,9 +33,9 @@ final class ActionSurfaceValidatorMissingSlotStub
|
|||||||
{
|
{
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::CrudListFirstResource)
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader)
|
->satisfy(ActionSurfaceSlot::ListHeader)
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value);
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -42,13 +43,13 @@ final class ActionSurfaceValidatorRunLogNoExportStub
|
|||||||
{
|
{
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::RunLog)
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::RunLog, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||||
->withDefaults(new \App\Support\Ui\ActionSurface\ActionSurfaceDefaults(
|
->withDefaults(new \App\Support\Ui\ActionSurface\ActionSurfaceDefaults(
|
||||||
moreGroupLabel: 'More',
|
moreGroupLabel: 'More',
|
||||||
exportIsDefaultBulkActionForReadOnly: false,
|
exportIsDefaultBulkActionForReadOnly: false,
|
||||||
))
|
))
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader)
|
->satisfy(ActionSurfaceSlot::ListHeader)
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup)
|
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup)
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState)
|
->satisfy(ActionSurfaceSlot::ListEmptyState)
|
||||||
->satisfy(ActionSurfaceSlot::DetailHeader);
|
->satisfy(ActionSurfaceSlot::DetailHeader);
|
||||||
@ -59,8 +60,36 @@ final class ActionSurfaceValidatorExemptSlotWithoutReasonStub
|
|||||||
{
|
{
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::CrudListFirstResource)
|
||||||
->setSlot(ActionSurfaceSlot::ListHeader, ActionSurfaceSlotRequirement::exempt())
|
->setSlot(ActionSurfaceSlot::ListHeader, ActionSurfaceSlotRequirement::exempt())
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListEmptyState)
|
||||||
|
->satisfy(ActionSurfaceSlot::DetailHeader);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class ActionSurfaceValidatorMissingSurfaceTypeStub
|
||||||
|
{
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, version: 2)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListHeader)
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListEmptyState)
|
||||||
|
->satisfy(ActionSurfaceSlot::DetailHeader);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class ActionSurfaceValidatorIncompatibleInspectAffordanceStub
|
||||||
|
{
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::CrudListFirstResource)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListHeader)
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
||||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu)
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu)
|
||||||
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup)
|
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup)
|
||||||
@ -69,6 +98,19 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final class ActionSurfaceValidatorPrimaryLinkColumnWithoutReasonStub
|
||||||
|
{
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListHeader)
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::PrimaryLinkColumn->value)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListEmptyState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final class ActionSurfaceValidatorNoDeclarationStub {}
|
final class ActionSurfaceValidatorNoDeclarationStub {}
|
||||||
|
|
||||||
function actionSurfaceComponent(string $className): ActionSurfaceDiscoveredComponent
|
function actionSurfaceComponent(string $className): ActionSurfaceDiscoveredComponent
|
||||||
@ -115,6 +157,42 @@ className: $className,
|
|||||||
expect($result->formatForAssertion())->toContain('Missing action-surface declaration');
|
expect($result->formatForAssertion())->toContain('Missing action-surface declaration');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('fails behavior-aware declarations when surface type is missing', function (): void {
|
||||||
|
$validator = new ActionSurfaceValidator(
|
||||||
|
profileDefinition: new ActionSurfaceProfileDefinition,
|
||||||
|
exemptions: new ActionSurfaceExemptions([]),
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorMissingSurfaceTypeStub::class)]);
|
||||||
|
|
||||||
|
expect($result->hasIssues())->toBeTrue();
|
||||||
|
expect($result->formatForAssertion())->toContain('Behavior-aware declarations must define a surface type');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails behavior-aware declarations when surface type and inspect affordance are incompatible', function (): void {
|
||||||
|
$validator = new ActionSurfaceValidator(
|
||||||
|
profileDefinition: new ActionSurfaceProfileDefinition,
|
||||||
|
exemptions: new ActionSurfaceExemptions([]),
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorIncompatibleInspectAffordanceStub::class)]);
|
||||||
|
|
||||||
|
expect($result->hasIssues())->toBeTrue();
|
||||||
|
expect($result->formatForAssertion())->toContain('Inspect affordance "view_action" is incompatible with surface type "crud_list_first_resource"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails primary-link-column inspect affordances without an explicit reason', function (): void {
|
||||||
|
$validator = new ActionSurfaceValidator(
|
||||||
|
profileDefinition: new ActionSurfaceProfileDefinition,
|
||||||
|
exemptions: new ActionSurfaceExemptions([]),
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorPrimaryLinkColumnWithoutReasonStub::class)]);
|
||||||
|
|
||||||
|
expect($result->hasIssues())->toBeTrue();
|
||||||
|
expect($result->formatForAssertion())->toContain('Primary link column inspect affordance requires a non-empty reason');
|
||||||
|
});
|
||||||
|
|
||||||
it('accepts missing declarations when explicit baseline exemption exists', function (): void {
|
it('accepts missing declarations when explicit baseline exemption exists', function (): void {
|
||||||
$validator = new ActionSurfaceValidator(
|
$validator = new ActionSurfaceValidator(
|
||||||
profileDefinition: new ActionSurfaceProfileDefinition,
|
profileDefinition: new ActionSurfaceProfileDefinition,
|
||||||
|
|||||||
@ -271,8 +271,13 @@ function tenantActionSurfaceSearchTitles($results): array
|
|||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
|
$relatedOnboardingIndex = array_search('related_onboarding_overflow', $moreActionNames, true);
|
||||||
|
$archiveIndex = array_search('archive', $moreActionNames, true);
|
||||||
|
|
||||||
expect($primaryRowActionNames)->not->toContain('view')
|
expect($primaryRowActionNames)->not->toContain('view')
|
||||||
->and($table->getRecordUrl($tenant))->toBe(TenantResource::getUrl('view', ['record' => $tenant]))
|
->and($table->getRecordUrl($tenant))->toBe(TenantResource::getUrl('view', ['record' => $tenant]))
|
||||||
->and($moreActionNames)->toContain('archive')
|
->and($moreActionNames)->toContain('archive')
|
||||||
->and($moreActionNames)->toContain('related_onboarding_overflow');
|
->and($moreActionNames)->toContain('related_onboarding_overflow')
|
||||||
|
->and($relatedOnboardingIndex)->toBe(0)
|
||||||
|
->and($archiveIndex)->toBe(count($moreActionNames) - 1);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -26,7 +26,9 @@
|
|||||||
$this->actingAs($platformUser, 'platform')
|
$this->actingAs($platformUser, 'platform')
|
||||||
->get(SystemOperationRunLinks::view($run))
|
->get(SystemOperationRunLinks::view($run))
|
||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
->assertSee('Run #'.(int) $run->getKey());
|
->assertSee('Operation #'.(int) $run->getKey())
|
||||||
|
->assertSee('Show all operations')
|
||||||
|
->assertSee('Go to runbooks');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not render raw context payloads in canonical run detail', function () {
|
it('does not render raw context payloads in canonical run detail', function () {
|
||||||
|
|||||||
@ -49,6 +49,8 @@
|
|||||||
$this->actingAs($platformUser, 'platform')
|
$this->actingAs($platformUser, 'platform')
|
||||||
->get('/system/ops/failures')
|
->get('/system/ops/failures')
|
||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
|
->assertSee('Failed operations')
|
||||||
|
->assertSee('Show all operations')
|
||||||
->assertSee(SystemOperationRunLinks::view($failedRun))
|
->assertSee(SystemOperationRunLinks::view($failedRun))
|
||||||
->assertDontSee(SystemOperationRunLinks::view($succeededRun));
|
->assertDontSee(SystemOperationRunLinks::view($succeededRun));
|
||||||
});
|
});
|
||||||
|
|||||||
@ -67,6 +67,8 @@
|
|||||||
$this->actingAs($platformUser, 'platform')
|
$this->actingAs($platformUser, 'platform')
|
||||||
->get('/system/ops/stuck')
|
->get('/system/ops/stuck')
|
||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
|
->assertSee('Stuck operations')
|
||||||
|
->assertSee('Show all operations')
|
||||||
->assertSee('#'.(int) $stuckQueued->getKey())
|
->assertSee('#'.(int) $stuckQueued->getKey())
|
||||||
->assertSee('#'.(int) $stuckRunning->getKey())
|
->assertSee('#'.(int) $stuckRunning->getKey())
|
||||||
->assertDontSee('#'.(int) $freshQueued->getKey());
|
->assertDontSee('#'.(int) $freshQueued->getKey());
|
||||||
|
|||||||
@ -2,7 +2,11 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\System\Pages\Ops\Failures;
|
||||||
|
use App\Filament\System\Pages\Ops\Runbooks;
|
||||||
use App\Filament\System\Pages\Ops\Runs;
|
use App\Filament\System\Pages\Ops\Runs;
|
||||||
|
use App\Filament\System\Pages\Ops\Stuck;
|
||||||
|
use App\Filament\System\Pages\Ops\ViewRun;
|
||||||
use App\Models\AuditLog;
|
use App\Models\AuditLog;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\PlatformUser;
|
use App\Models\PlatformUser;
|
||||||
@ -11,6 +15,8 @@
|
|||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\System\SystemOperationRunLinks;
|
use App\Support\System\SystemOperationRunLinks;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Filament\Actions\Action;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Notifications\DatabaseNotification;
|
use Illuminate\Notifications\DatabaseNotification;
|
||||||
@ -29,7 +35,11 @@
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hides triage actions for operators without platform.operations.manage', function () {
|
afterEach(function () {
|
||||||
|
CarbonImmutable::setTestNow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the operations list scan-first with one go to runbooks cta', function () {
|
||||||
$run = OperationRun::factory()->create([
|
$run = OperationRun::factory()->create([
|
||||||
'status' => OperationRunStatus::Completed->value,
|
'status' => OperationRunStatus::Completed->value,
|
||||||
'outcome' => OperationRunOutcome::Failed->value,
|
'outcome' => OperationRunOutcome::Failed->value,
|
||||||
@ -46,13 +56,132 @@
|
|||||||
|
|
||||||
$this->actingAs($viewOnlyUser, 'platform');
|
$this->actingAs($viewOnlyUser, 'platform');
|
||||||
|
|
||||||
Livewire::test(Runs::class)
|
$livewire = Livewire::test(Runs::class)
|
||||||
->assertTableActionHidden('retry', $run)
|
->assertCanSeeTableRecords([$run])
|
||||||
->assertTableActionHidden('cancel', $run)
|
->assertActionVisible('go_to_runbooks')
|
||||||
->assertTableActionHidden('mark_investigated', $run);
|
->assertActionExists('go_to_runbooks', fn (Action $action): bool => $action->getLabel() === 'Go to runbooks' && $action->getUrl() === Runbooks::getUrl(panel: 'system'));
|
||||||
|
|
||||||
|
$table = $livewire->instance()->getTable();
|
||||||
|
$emptyStateActions = collect($table->getEmptyStateActions())
|
||||||
|
->map(fn (Action $action): array => [
|
||||||
|
'name' => $action->getName(),
|
||||||
|
'label' => $action->getLabel(),
|
||||||
|
'url' => $action->getUrl(),
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
expect($livewire->instance()->getTitle())->toBe('Operations')
|
||||||
|
->and($table->getActions())->toBeEmpty()
|
||||||
|
->and($table->getBulkActions())->toBeEmpty()
|
||||||
|
->and($table->getRecordUrl($run))->toBe(SystemOperationRunLinks::view($run))
|
||||||
|
->and($emptyStateActions)->toBe([
|
||||||
|
[
|
||||||
|
'name' => 'go_to_runbooks_empty',
|
||||||
|
'label' => 'Go to runbooks',
|
||||||
|
'url' => Runbooks::getUrl(panel: 'system'),
|
||||||
|
],
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows manage operators to run triage actions with audit logs and queued-run ux contract', function () {
|
it('keeps the failed operations list scan-first with one show all operations cta', function () {
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Failed->value,
|
||||||
|
'type' => 'inventory_sync',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$viewOnlyUser = PlatformUser::factory()->create([
|
||||||
|
'capabilities' => [
|
||||||
|
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||||
|
PlatformCapabilities::OPERATIONS_VIEW,
|
||||||
|
],
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($viewOnlyUser, 'platform');
|
||||||
|
|
||||||
|
$livewire = Livewire::test(Failures::class)
|
||||||
|
->assertCanSeeTableRecords([$run])
|
||||||
|
->assertActionVisible('show_all_operations')
|
||||||
|
->assertActionExists('show_all_operations', fn (Action $action): bool => $action->getLabel() === 'Show all operations' && $action->getUrl() === SystemOperationRunLinks::index());
|
||||||
|
|
||||||
|
$table = $livewire->instance()->getTable();
|
||||||
|
$emptyStateActions = collect($table->getEmptyStateActions())
|
||||||
|
->map(fn (Action $action): array => [
|
||||||
|
'name' => $action->getName(),
|
||||||
|
'label' => $action->getLabel(),
|
||||||
|
'url' => $action->getUrl(),
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
expect($livewire->instance()->getTitle())->toBe('Failed operations')
|
||||||
|
->and($table->getActions())->toBeEmpty()
|
||||||
|
->and($table->getBulkActions())->toBeEmpty()
|
||||||
|
->and($table->getRecordUrl($run))->toBe(SystemOperationRunLinks::view($run))
|
||||||
|
->and($emptyStateActions)->toBe([
|
||||||
|
[
|
||||||
|
'name' => 'show_all_operations_empty',
|
||||||
|
'label' => 'Show all operations',
|
||||||
|
'url' => SystemOperationRunLinks::index(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the stuck operations list scan-first with one show all operations cta', function () {
|
||||||
|
config()->set('tenantpilot.system_console.stuck_thresholds.queued_minutes', 10);
|
||||||
|
config()->set('tenantpilot.system_console.stuck_thresholds.running_minutes', 20);
|
||||||
|
|
||||||
|
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-27 10:00:00'));
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'status' => OperationRunStatus::Queued->value,
|
||||||
|
'outcome' => OperationRunOutcome::Pending->value,
|
||||||
|
'type' => 'inventory_sync',
|
||||||
|
'created_at' => now()->subMinutes(30),
|
||||||
|
'started_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$viewOnlyUser = PlatformUser::factory()->create([
|
||||||
|
'capabilities' => [
|
||||||
|
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||||
|
PlatformCapabilities::OPERATIONS_VIEW,
|
||||||
|
],
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($viewOnlyUser, 'platform');
|
||||||
|
|
||||||
|
$livewire = Livewire::test(Stuck::class)
|
||||||
|
->assertCanSeeTableRecords([$run])
|
||||||
|
->assertActionVisible('show_all_operations')
|
||||||
|
->assertActionExists('show_all_operations', fn (Action $action): bool => $action->getLabel() === 'Show all operations' && $action->getUrl() === SystemOperationRunLinks::index());
|
||||||
|
|
||||||
|
$table = $livewire->instance()->getTable();
|
||||||
|
$emptyStateActions = collect($table->getEmptyStateActions())
|
||||||
|
->map(fn (Action $action): array => [
|
||||||
|
'name' => $action->getName(),
|
||||||
|
'label' => $action->getLabel(),
|
||||||
|
'url' => $action->getUrl(),
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
expect($livewire->instance()->getTitle())->toBe('Stuck operations')
|
||||||
|
->and($table->getActions())->toBeEmpty()
|
||||||
|
->and($table->getBulkActions())->toBeEmpty()
|
||||||
|
->and($table->getRecordUrl($run))->toBe(SystemOperationRunLinks::view($run))
|
||||||
|
->and($emptyStateActions)->toBe([
|
||||||
|
[
|
||||||
|
'name' => 'show_all_operations_empty',
|
||||||
|
'label' => 'Show all operations',
|
||||||
|
'url' => SystemOperationRunLinks::index(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps detail-page triage, return paths, and audit behavior for manage operators', function () {
|
||||||
NotificationFacade::fake();
|
NotificationFacade::fake();
|
||||||
|
|
||||||
$failedRun = OperationRun::factory()->create([
|
$failedRun = OperationRun::factory()->create([
|
||||||
@ -61,6 +190,14 @@
|
|||||||
'type' => 'inventory_sync',
|
'type' => 'inventory_sync',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$runningRun = OperationRun::factory()->create([
|
||||||
|
'status' => OperationRunStatus::Running->value,
|
||||||
|
'outcome' => OperationRunOutcome::Pending->value,
|
||||||
|
'type' => 'inventory_sync',
|
||||||
|
'created_at' => now()->subMinutes(15),
|
||||||
|
'started_at' => now()->subMinutes(10),
|
||||||
|
]);
|
||||||
|
|
||||||
$manageUser = PlatformUser::factory()->create([
|
$manageUser = PlatformUser::factory()->create([
|
||||||
'capabilities' => [
|
'capabilities' => [
|
||||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||||
@ -72,9 +209,26 @@
|
|||||||
|
|
||||||
$this->actingAs($manageUser, 'platform');
|
$this->actingAs($manageUser, 'platform');
|
||||||
|
|
||||||
Livewire::test(Runs::class)
|
$failedRunView = Livewire::test(ViewRun::class, [
|
||||||
->callTableAction('retry', $failedRun)
|
'run' => $failedRun,
|
||||||
->assertHasNoTableActionErrors()
|
])
|
||||||
|
->assertActionVisible('show_all_operations')
|
||||||
|
->assertActionExists('show_all_operations', fn (Action $action): bool => $action->getLabel() === 'Show all operations' && $action->getUrl() === SystemOperationRunLinks::index())
|
||||||
|
->assertActionVisible('go_to_runbooks')
|
||||||
|
->assertActionExists('go_to_runbooks', fn (Action $action): bool => $action->getLabel() === 'Go to runbooks' && $action->getUrl() === Runbooks::getUrl(panel: 'system'))
|
||||||
|
->assertActionVisible('retry')
|
||||||
|
->assertActionExists('retry', fn (Action $action): bool => $action->getLabel() === 'Retry' && $action->isConfirmationRequired())
|
||||||
|
->assertActionVisible('mark_investigated')
|
||||||
|
->assertActionExists('mark_investigated', fn (Action $action): bool => $action->getLabel() === 'Mark investigated' && $action->isConfirmationRequired())
|
||||||
|
->assertActionHidden('cancel');
|
||||||
|
|
||||||
|
expect($failedRunView->instance()->getTitle())->toBe('Operation #'.(int) $failedRun->getKey());
|
||||||
|
|
||||||
|
Livewire::test(ViewRun::class, [
|
||||||
|
'run' => $failedRun,
|
||||||
|
])
|
||||||
|
->callAction('retry')
|
||||||
|
->assertHasNoActionErrors()
|
||||||
->assertNotified('Inventory sync queued');
|
->assertNotified('Inventory sync queued');
|
||||||
|
|
||||||
NotificationFacade::assertNothingSent();
|
NotificationFacade::assertNothingSent();
|
||||||
@ -91,14 +245,73 @@
|
|||||||
|
|
||||||
$this->get(SystemOperationRunLinks::view($retriedRun))
|
$this->get(SystemOperationRunLinks::view($retriedRun))
|
||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
->assertSee('Run #'.(int) $retriedRun?->getKey());
|
->assertSee('Operation #'.(int) $retriedRun?->getKey())
|
||||||
|
->assertSee('Show all operations')
|
||||||
|
->assertSee('Go to runbooks');
|
||||||
|
|
||||||
Livewire::test(Runs::class)
|
Livewire::test(ViewRun::class, [
|
||||||
->callTableAction('mark_investigated', $failedRun, data: [
|
'run' => $failedRun,
|
||||||
|
])
|
||||||
|
->callAction('mark_investigated', data: [
|
||||||
'reason' => 'Checked by platform operations',
|
'reason' => 'Checked by platform operations',
|
||||||
])
|
])
|
||||||
->assertHasNoTableActionErrors();
|
->assertHasNoActionErrors()
|
||||||
|
->assertNotified('Run marked as investigated');
|
||||||
|
|
||||||
|
Livewire::test(ViewRun::class, [
|
||||||
|
'run' => $runningRun,
|
||||||
|
])
|
||||||
|
->assertActionVisible('show_all_operations')
|
||||||
|
->assertActionVisible('go_to_runbooks')
|
||||||
|
->assertActionHidden('retry')
|
||||||
|
->assertActionVisible('cancel')
|
||||||
|
->assertActionExists('cancel', fn (Action $action): bool => $action->getLabel() === 'Cancel' && $action->isConfirmationRequired())
|
||||||
|
->assertActionVisible('mark_investigated')
|
||||||
|
->callAction('cancel')
|
||||||
|
->assertHasNoActionErrors()
|
||||||
|
->assertNotified('Run cancelled');
|
||||||
|
|
||||||
expect(AuditLog::query()->where('action', 'platform.system_console.retry')->exists())->toBeTrue();
|
expect(AuditLog::query()->where('action', 'platform.system_console.retry')->exists())->toBeTrue();
|
||||||
|
expect(AuditLog::query()->where('action', 'platform.system_console.cancel')->exists())->toBeTrue();
|
||||||
expect(AuditLog::query()->where('action', 'platform.system_console.mark_investigated')->exists())->toBeTrue();
|
expect(AuditLog::query()->where('action', 'platform.system_console.mark_investigated')->exists())->toBeTrue();
|
||||||
|
|
||||||
|
$runningRun->refresh();
|
||||||
|
|
||||||
|
expect((string) $runningRun->status)->toBe(OperationRunStatus::Completed->value)
|
||||||
|
->and((string) $runningRun->outcome)->toBe(OperationRunOutcome::Failed->value);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps detail inspection and navigation available while hiding triage for view-only operators', function () {
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Failed->value,
|
||||||
|
'type' => 'inventory_sync',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$viewOnlyUser = PlatformUser::factory()->create([
|
||||||
|
'capabilities' => [
|
||||||
|
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||||
|
PlatformCapabilities::OPERATIONS_VIEW,
|
||||||
|
],
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($viewOnlyUser, 'platform');
|
||||||
|
|
||||||
|
Livewire::test(ViewRun::class, [
|
||||||
|
'run' => $run,
|
||||||
|
])
|
||||||
|
->assertActionVisible('show_all_operations')
|
||||||
|
->assertActionExists('show_all_operations', fn (Action $action): bool => $action->getLabel() === 'Show all operations' && $action->getUrl() === SystemOperationRunLinks::index())
|
||||||
|
->assertActionVisible('go_to_runbooks')
|
||||||
|
->assertActionExists('go_to_runbooks', fn (Action $action): bool => $action->getLabel() === 'Go to runbooks' && $action->getUrl() === Runbooks::getUrl(panel: 'system'))
|
||||||
|
->assertActionHidden('retry')
|
||||||
|
->assertActionHidden('cancel')
|
||||||
|
->assertActionHidden('mark_investigated');
|
||||||
|
|
||||||
|
$this->get(SystemOperationRunLinks::view($run))
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('Operation #'.(int) $run->getKey())
|
||||||
|
->assertSee('Show all operations')
|
||||||
|
->assertSee('Go to runbooks');
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user